Initial Commit

This commit is contained in:
2026-03-27 09:21:41 +01:00
commit e9b6412d71
40 changed files with 6801 additions and 0 deletions
@@ -0,0 +1,337 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
26ED92632F759EEA0025419D /* Mobile Music Assistant */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "Mobile Music Assistant";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
26ED925E2F759EEA0025419D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
26ED92582F759EEA0025419D = {
isa = PBXGroup;
children = (
26ED92632F759EEA0025419D /* Mobile Music Assistant */,
26ED92622F759EEA0025419D /* Products */,
);
sourceTree = "<group>";
};
26ED92622F759EEA0025419D /* Products */ = {
isa = PBXGroup;
children = (
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
26ED92602F759EEA0025419D /* Mobile Music Assistant */ = {
isa = PBXNativeTarget;
buildConfigurationList = 26ED926C2F759EEB0025419D /* Build configuration list for PBXNativeTarget "Mobile Music Assistant" */;
buildPhases = (
26ED925D2F759EEA0025419D /* Sources */,
26ED925E2F759EEA0025419D /* Frameworks */,
26ED925F2F759EEA0025419D /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
26ED92632F759EEA0025419D /* Mobile Music Assistant */,
);
name = "Mobile Music Assistant";
packageProductDependencies = (
);
productName = "Mobile Music Assistant";
productReference = 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
26ED92592F759EEA0025419D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2640;
TargetAttributes = {
26ED92602F759EEA0025419D = {
CreatedOnToolsVersion = 26.4;
};
};
};
buildConfigurationList = 26ED925C2F759EEA0025419D /* Build configuration list for PBXProject "Mobile Music Assistant" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 26ED92582F759EEA0025419D;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 26ED92622F759EEA0025419D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
26ED92602F759EEA0025419D /* Mobile Music Assistant */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
26ED925F2F759EEA0025419D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
26ED925D2F759EEA0025419D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
26ED926A2F759EEB0025419D /* 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;
DEVELOPMENT_TEAM = EKFHUHT63T;
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 = 26.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
26ED926B2F759EEB0025419D /* 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";
DEVELOPMENT_TEAM = EKFHUHT63T;
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 = 26.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
26ED926D2F759EEB0025419D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
26ED926E2F759EEB0025419D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
26ED925C2F759EEA0025419D /* Build configuration list for PBXProject "Mobile Music Assistant" */ = {
isa = XCConfigurationList;
buildConfigurations = (
26ED926A2F759EEB0025419D /* Debug */,
26ED926B2F759EEB0025419D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
26ED926C2F759EEB0025419D /* Build configuration list for PBXNativeTarget "Mobile Music Assistant" */ = {
isa = XCConfigurationList;
buildConfigurations = (
26ED926D2F759EEB0025419D /* Debug */,
26ED926E2F759EEB0025419D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 26ED92592F759EEA0025419D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,14 @@
<?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>SchemeUserState</key>
<dict>
<key>Mobile Music Assistant.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+24
View File
@@ -0,0 +1,24 @@
//
// ContentView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}
@@ -0,0 +1,145 @@
# Music Assistant Audio Streaming Integration
## Übersicht
Um Audio vom Music Assistant Server auf dem iPhone abzuspielen, müssen wir:
1. Stream-URL vom Server anfordern
2. AVPlayer mit dieser URL konfigurieren
3. Playback-Status zum Server zurückmelden
## Stream-URL erhalten
### API Call: `player_queues/cmd/get_stream_url`
```swift
func getStreamURL(queueId: String, queueItemId: String) async throws -> URL {
let response = try await webSocketClient.sendCommand(
"player_queues/cmd/get_stream_url",
args: [
"queue_id": queueId,
"queue_item_id": queueItemId
]
)
guard let result = response.result,
let urlString = result.value as? String,
let url = URL(string: urlString) else {
throw ClientError.serverError("Invalid stream URL")
}
return url
}
```
### Beispiel Stream-URL Format
```
http://MA_SERVER:8095/api/stream/<queue_id>/<queue_item_id>
```
## Implementierungsschritte
### 1. Stream-URL in MAService hinzufügen
```swift
// In MAService.swift
func getStreamURL(queueId: String, queueItemId: String) async throws -> URL {
let response = try await webSocketClient.sendCommand(
"player_queues/cmd/get_stream_url",
args: [
"queue_id": queueId,
"queue_item_id": queueItemId
]
)
guard let result = response.result else {
throw MAWebSocketClient.ClientError.serverError("No result")
}
// Try to extract URL from response
if let urlString = result.value as? String,
let url = URL(string: urlString) {
return url
}
throw MAWebSocketClient.ClientError.serverError("Invalid stream URL format")
}
```
### 2. Integration in MAAudioPlayer
```swift
// In MAAudioPlayer.swift
func playQueueItem(_ item: MAQueueItem, queueId: String) async throws {
logger.info("Playing queue item: \(item.name)")
// Get stream URL from server
let streamURL = try await service.getStreamURL(
queueId: queueId,
queueItemId: item.queueItemId
)
// Load and play
loadAndPlay(item: item, streamURL: streamURL)
}
```
### 3. Status-Updates zum Server senden
```swift
// Player-Status synchronisieren
func syncPlayerState() async throws {
try await service.webSocketClient.sendCommand(
"players/cmd/update_state",
args: [
"player_id": "ios_device",
"state": isPlaying ? "playing" : "paused",
"current_time": currentTime,
"volume": Int(volume * 100)
]
)
}
```
## Format-Unterstützung
AVPlayer unterstützt nativ:
- ✅ MP3
- ✅ AAC
- ✅ M4A
- ✅ WAV
- ✅ AIFF
- ✅ HLS Streams
Für FLAC benötigt man:
- ⚠️ Server-seitige Transcoding (MA kann das automatisch)
- 🔧 Oder: Third-party Decoder (z.B. via AudioToolbox)
## Authentifizierung für Stream-URLs
Stream-URLs erfordern möglicherweise den Auth-Token:
```swift
var request = URLRequest(url: streamURL)
if let token = service.authManager.currentToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let playerItem = AVPlayerItem(asset: AVURLAsset(url: streamURL, options: [
"AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]
]))
```
## Nächste Schritte
1. ✅ Implementiere `getStreamURL()` in MAService
2. ✅ Update `MAAudioPlayer.playQueueItem()`
3. ✅ Teste mit verschiedenen Audio-Formaten
4. ✅ Implementiere Player-State-Sync zum Server
5. ✅ Handle Netzwerk-Fehler & Buffering
## Referenzen
- [MA Server API Docs](http://YOUR_SERVER:8095/api-docs)
- [AVPlayer Documentation](https://developer.apple.com/documentation/avfoundation/avplayer)
- [AVAudioSession Best Practices](https://developer.apple.com/documentation/avfaudio/avaudiosession)
@@ -0,0 +1,218 @@
# Troubleshooting: musicassistant-app.hanold.online
## Problem Analysis
When connecting to `https://musicassistant-app.hanold.online`, you're getting an **NSURLErrorDomain** error. This typically indicates:
1. **DNS Resolution Failed** - Domain cannot be resolved to an IP
2. **Server Not Reachable** - Domain exists but server is offline/unreachable
3. **Firewall/Network Blocking** - Network is blocking the connection
4. **Port Not Open** - Port 8095 might not be accessible from the internet
## Diagnostic Steps
### 1. Verify Domain Resolution
**On Mac/Linux:**
```bash
# Check if domain resolves
nslookup musicassistant-app.hanold.online
# Check connectivity
ping musicassistant-app.hanold.online
# Test HTTPS connection
curl -v https://musicassistant-app.hanold.online:8095
# Test specific endpoint
curl -v https://musicassistant-app.hanold.online:8095/api/auth/login
```
**Expected results:**
- `nslookup` should return an IP address
- `ping` should show responses (if ICMP is allowed)
- `curl` should connect (even if it returns auth error)
### 2. Check Common Issues
#### Issue A: Domain Not Configured
**Symptoms:** `nslookup` fails or returns NXDOMAIN
**Solutions:**
- Verify DNS A record exists for `musicassistant-app.hanold.online`
- Wait for DNS propagation (can take 24-48 hours)
- Try using IP address directly: `https://YOUR_IP:8095`
#### Issue B: Port Not Open
**Symptoms:** Domain resolves but connection times out
**Solutions:**
- Check firewall allows port 8095
- Verify router port forwarding is configured
- Test with `telnet YOUR_IP 8095` or `nc -zv YOUR_IP 8095`
#### Issue C: SSL Certificate Issues
**Symptoms:** Connection fails with SSL error
**Solutions:**
- Verify SSL certificate is valid: `openssl s_client -connect musicassistant-app.hanold.online:8095`
- Ensure certificate matches domain name
- Check certificate is not expired
#### Issue D: Music Assistant Not Running
**Symptoms:** Domain resolves but no response
**Solutions:**
- Check Music Assistant is running: `systemctl status music-assistant`
- Verify it's listening on all interfaces (0.0.0.0)
- Check Music Assistant logs for errors
### 3. Test from Different Networks
Try connecting from:
- **Mobile Data** (not WiFi) - Tests if home network is the issue
- **Another WiFi Network** - Tests if ISP is blocking
- **VPN** - Tests if geographic restrictions apply
### 4. Check Music Assistant Configuration
Music Assistant needs to be configured for external access:
**config.yaml should have:**
```yaml
server:
host: 0.0.0.0 # Listen on all interfaces
port: 8095
ssl_certificate: /path/to/cert.pem
ssl_key: /path/to/key.pem
```
### 5. Verify Network Path
```bash
# Trace route to server
traceroute musicassistant-app.hanold.online
# Check if specific port is reachable
telnet musicassistant-app.hanold.online 8095
```
## Common Error Codes
| Error Code | Meaning | Solution |
|------------|---------|----------|
| -1003 | Cannot find host | DNS not resolving - check domain configuration |
| -1004 | Cannot connect to host | Server unreachable - check firewall/port |
| -1001 | Request timed out | Server not responding - check Music Assistant is running |
| -1200 | Secure connection failed | SSL/TLS error - check certificate |
| -1009 | Not connected to internet | Check device network connection |
## Quick Fixes
### Fix 1: Use IP Address Instead
If DNS is the issue:
```
https://YOUR_PUBLIC_IP:8095
```
### Fix 2: Use Local Access
If on same network:
```
http://LOCAL_IP:8095 (e.g., http://192.168.1.100:8095)
```
### Fix 3: Test with HTTP First
Rule out SSL issues:
```
http://musicassistant-app.hanold.online:8095
```
⚠️ Only for testing! Use HTTPS in production.
### Fix 4: Check Port in URL
Ensure you're including the port:
-`https://musicassistant-app.hanold.online:8095`
-`https://musicassistant-app.hanold.online` (will try port 443)
## App-Side Improvements
The app now provides better error messages:
```swift
// DNS lookup failed
"DNS lookup failed. Cannot resolve domain name. Check the URL."
// Cannot connect
"Cannot connect to server. The server might be offline or unreachable."
// Timeout
"Connection timed out. The server is taking too long to respond."
```
These messages will appear in the login error alert.
## Recommended Setup
For external access to Music Assistant:
### Option 1: Direct Access (Simple but less secure)
1. Configure router port forwarding: External 8095 → Internal 8095
2. Set up Dynamic DNS (if you don't have static IP)
3. Configure SSL certificate for domain
4. Allow port 8095 in firewall
### Option 2: Reverse Proxy (Recommended)
1. Use nginx/Caddy as reverse proxy
2. Proxy HTTPS traffic to Music Assistant
3. Let reverse proxy handle SSL
4. Use standard HTTPS port 443
**Example nginx config:**
```nginx
server {
listen 443 ssl;
server_name musicassistant-app.hanold.online;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8095;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
### Option 3: Tailscale/WireGuard VPN (Most Secure)
1. Set up Tailscale or WireGuard
2. Access Music Assistant via VPN
3. No port forwarding needed
4. Fully encrypted end-to-end
## Testing Checklist
- [ ] Domain resolves in DNS lookup
- [ ] Server responds to ping (if enabled)
- [ ] Port 8095 is open and accessible
- [ ] Music Assistant is running
- [ ] SSL certificate is valid
- [ ] Firewall allows connections
- [ ] Can access via web browser
- [ ] WebSocket connections work
## If Still Not Working
1. **Check Music Assistant logs** on the server
2. **Enable debug logging** in the iOS app (check Xcode console)
3. **Try from web browser** first to isolate app issues
4. **Verify with curl** to test raw HTTP connection
5. **Check router logs** for blocked connections
## Contact Information
If you've verified all the above and it still doesn't work, the issue is likely:
- **Server-side configuration** - Check Music Assistant setup
- **Network infrastructure** - Check router/firewall
- **DNS configuration** - Verify domain points to correct IP
@@ -0,0 +1,138 @@
# HTTPS Connection Issues - Troubleshooting Guide
## Problem: Login doesn't work via HTTPS
If you're experiencing connection issues when using HTTPS (e.g., `https://192.168.1.100:8095`), it's likely due to **App Transport Security (ATS)** blocking the connection.
## Quick Fix: Enable App Transport Security Exceptions
### Option 1: Allow All Insecure Loads (Development Only)
⚠️ **WARNING: Only use this for development/testing! Never in production!**
Add to your `Info.plist`:
```xml
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
```
**How to add in Xcode:**
1. Select your target → Info tab
2. Hover over any row and click the "+" button
3. Type "App Transport Security Settings"
4. Click the disclosure triangle to expand
5. Add a row inside: "Allow Arbitrary Loads" = YES
### Option 2: Allow Specific Domain (Safer)
If you know your server's domain/IP:
```xml
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>192.168.1.100</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
```
## Why Does This Happen?
1. **Self-Signed Certificates**: Most local Music Assistant servers use self-signed SSL certificates
2. **ATS Requirements**: iOS requires valid certificates from trusted Certificate Authorities
3. **IP Addresses**: HTTPS with IP addresses (not domains) often fails certificate validation
## What Was Fixed in Code:
✅ Better error logging in `MAAuthManager.login()`
✅ Proper HTTP status code handling (200, 401, etc.)
✅ Detailed error messages in console
✅ Timeout configuration for slow networks
## Check the Console for Errors
When login fails, check Xcode console for messages like:
```
[ERROR] Login failed with status 401
[ERROR] Login network error: The certificate for this server is invalid
[ERROR] NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9802)
```
These indicate ATS is blocking the connection.
## Production Solution
For production apps, you should:
1. **Get a valid SSL certificate** (Let's Encrypt, etc.)
2. **Use a proper domain** instead of IP address
3. **Configure DNS** to point to your server
4. **Remove ATS exceptions** from Info.plist
## Testing HTTPS
To verify your HTTPS connection works:
1. **In Safari**: Visit `https://YOUR_SERVER:8095`
- If you see a certificate warning, that's the issue
2. **In Terminal**:
```bash
curl -v https://YOUR_SERVER:8095/api/auth/login
```
- Check for SSL errors
3. **Check Server Logs**: Music Assistant should log connection attempts
## Alternative: Use HTTP Instead
For local network use, HTTP is fine:
- Use `http://192.168.1.100:8095`
- No certificate issues
- Still secure on your local network
- ATS allows localhost/local IP HTTP connections
## Complete Info.plist with ATS Exception
```xml
<?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>
<!-- ... other keys ... -->
<!-- Background Audio -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<!-- App Transport Security (for self-signed HTTPS) -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
```
## Summary
**Problem**: iOS blocks HTTPS connections to servers with invalid/self-signed certificates
**Solution**: Add ATS exception to Info.plist
**Best Practice**: Use HTTP for local servers, HTTPS with valid certificates for production
@@ -0,0 +1,233 @@
# How to Use Long-Lived Tokens
## Why Use Long-Lived Tokens?
**More Secure** - Password is never stored on device
**Convenient** - No repeated logins (token valid for months/years)
**Revocable** - Can be invalidated from server without changing password
**Best Practice** - Official Music Assistant apps use this method
## Creating a Long-Lived Token
### Method 1: Via Web Interface (Recommended)
1. **Open Music Assistant in your browser:**
```
https://musicassistant-app.hanold.online
```
2. **Login with your username and password**
3. **Go to Settings:**
- Click the gear icon (⚙️) in the top right
- Select "Users" or "User Management"
4. **Create a Token:**
- Find your user account
- Click "Create Token" or "Generate Long-Lived Access Token"
- Give it a name (e.g., "iPhone App")
- Set expiration (optional - can be "Never")
5. **Copy the Token:**
- Token will be displayed ONCE
- Copy it immediately!
- **Important:** You can't see it again after closing the dialog
6. **Use in App:**
- Open the iOS app
- Select "Long-Lived Token" as login method
- Paste the token
- Enter server URL
- Click "Connect"
### Method 2: Via API (Advanced)
If you need to automate token creation:
```bash
# Step 1: Login to get short-lived token
curl -X POST https://musicassistant-app.hanold.online/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"YOUR_USERNAME","password":"YOUR_PASSWORD"}'
# Response: {"access_token": "eyJ..."}
# Step 2: Create long-lived token (requires WebSocket connection)
# This is complex - use the web interface instead!
```
## Using the Token in the App
### Option A: Long-Lived Token (Recommended)
1. **In LoginView, select "Long-Lived Token"**
2. **Enter server URL:**
```
https://musicassistant-app.hanold.online
```
(No port number needed if using reverse proxy on 443!)
3. **Paste your token:**
- Token looks like: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...`
- Very long string (100+ characters)
- Contains dots (.)
4. **Click "Connect"**
### Option B: Username & Password
1. **In LoginView, select "Username & Password"**
2. **Enter credentials**
3. **App will create a long-lived token automatically**
4. **Token is saved in Keychain for future use**
## Token Security
### Stored Securely
- Token is saved in iOS Keychain
- Encrypted by the system
- Not accessible to other apps
- Survives app reinstalls
### Token Protection
- Never share your token
- Treat it like a password
- Revoke if compromised
- Create device-specific tokens
### Revoking a Token
If your token is compromised:
1. **Go to Music Assistant Settings → Users**
2. **Find the token in the list**
3. **Click "Revoke" or "Delete"**
4. **Create a new token**
5. **Update the app with new token**
## Troubleshooting
### "Invalid Token" Error
**Causes:**
- Token was revoked on server
- Token expired
- Token not copied completely
- Server URL mismatch
**Solution:**
- Generate a new token
- Copy entire token (check for truncation)
- Verify server URL matches
### "Connection Failed" Error
**Causes:**
- Server URL incorrect
- Server offline
- Network issues
- Reverse proxy misconfiguration
**Solution:**
- Test server URL in browser
- Check server is running
- Verify network connectivity
- Check reverse proxy logs
### Token Not Working After Server Upgrade
**Cause:**
- Tokens may be invalidated during MA upgrades
**Solution:**
- Generate a new token
- Update in app
## Comparison: Token vs Credentials
| Aspect | Long-Lived Token | Username & Password |
|--------|-----------------|---------------------|
| Security | ✅ Better | ⚠️ Password stored temporarily |
| Convenience | ✅ No repeated logins | ❌ Manual login needed |
| Revocability | ✅ Easy to revoke | ⚠️ Must change password |
| Setup | ⚠️ Initial setup required | ✅ Simple |
| Recommended | ✅ Yes | ⚠️ For first setup only |
## Best Practices
1. **Use Long-Lived Tokens** for production
2. **Create device-specific tokens** (one per iPhone/iPad)
3. **Name tokens clearly** (e.g., "iPhone 15 Pro")
4. **Set reasonable expiration** (e.g., 1 year)
5. **Rotate tokens periodically** (every 6-12 months)
6. **Revoke unused tokens** to reduce security risk
## Token Format
A Music Assistant token looks like:
```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzIiwiaWF0IjoxNjc4ODg1MjAwLCJleHAiOjE5OTQyNDUyMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
```
**Structure:**
- Three parts separated by dots (.)
- Base64-encoded JSON
- Signed by server
- Contains user ID and expiration
**Do NOT:**
- Manually edit the token
- Share it publicly
- Commit to git repositories
- Include in screenshots
## Integration with App
The app handles tokens automatically:
```swift
// Token is saved securely
service.authManager.saveToken(serverURL: url, token: token)
// Token is used for all API calls
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
// Token persists across app restarts
// Loaded automatically from Keychain
```
## Advantages Over Password
1. **Granular Control**
- Different tokens for different devices
- Revoke one without affecting others
2. **Audit Trail**
- See which tokens are active
- Track last used time
- Monitor token usage
3. **No Password Exposure**
- Password never leaves browser
- Token can't be used to change password
- Limited scope of damage if compromised
4. **Performance**
- No password hashing on each request
- Direct token validation
- Faster authentication
## Summary
**For Regular Use:**
→ Use **Long-Lived Token** method
→ Create token via web interface
→ Copy/paste into app
→ Store in Keychain
→ Enjoy seamless authentication!
**For Initial Setup:**
→ Use **Username & Password** once
→ App creates token automatically
→ Switch to token method for security
→ Revoke password-created tokens if needed
@@ -0,0 +1,381 @@
# Reverse Proxy Configuration for Music Assistant
## Common Issues with Reverse Proxy Setup
When using a reverse proxy (nginx, Caddy, Traefik, etc.), there are specific configuration requirements for Music Assistant to work properly.
## Critical: WebSocket Support
Music Assistant **requires WebSocket support** for real-time communication. Your reverse proxy must:
1. ✅ Allow WebSocket upgrade requests
2. ✅ Proxy WebSocket connections properly
3. ✅ Keep connections alive (no timeout)
4. ✅ Forward correct headers
## Configuration Examples
### nginx Configuration (Recommended)
```nginx
# /etc/nginx/sites-available/musicassistant
server {
listen 443 ssl http2;
server_name musicassistant-app.hanold.online;
# SSL Configuration
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
# SSL Settings (Let's Encrypt recommended)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Timeouts (important for WebSocket)
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Max body size (for uploads)
client_max_body_size 100M;
location / {
# Proxy to Music Assistant
proxy_pass http://localhost:8095;
# Required headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket Support (CRITICAL!)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Disable buffering for WebSocket
proxy_buffering off;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name musicassistant-app.hanold.online;
return 301 https://$server_name$request_uri;
}
```
### Caddy Configuration (Simplest)
```caddy
musicassistant-app.hanold.online {
reverse_proxy localhost:8095 {
# Caddy handles WebSocket automatically
# No extra config needed!
}
}
```
### Apache Configuration
```apache
<VirtualHost *:443>
ServerName musicassistant-app.hanold.online
SSLEngine on
SSLCertificateFile /path/to/cert.pem
SSLCertificateKeyFile /path/to/key.pem
# Enable WebSocket
ProxyRequests Off
ProxyPreserveHost On
# WebSocket support
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) ws://localhost:8095/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) http://localhost:8095/$1 [P,L]
ProxyPass / http://localhost:8095/
ProxyPassReverse / http://localhost:8095/
</VirtualHost>
```
## Diagnostic Steps for Reverse Proxy
### 1. Test Reverse Proxy Directly
```bash
# Test from server itself
curl -I http://localhost:8095
# Test HTTPS endpoint
curl -I https://musicassistant-app.hanold.online
# Test WebSocket upgrade
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
https://musicassistant-app.hanold.online/ws
```
### 2. Check Reverse Proxy Logs
**nginx:**
```bash
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/nginx/access.log
```
**Caddy:**
```bash
sudo journalctl -u caddy -f
```
**Look for:**
- WebSocket upgrade requests
- 502/504 errors (backend not responding)
- SSL/TLS errors
- Connection timeouts
### 3. Verify Music Assistant is Accessible Locally
On the server:
```bash
# Test Music Assistant directly
curl http://localhost:8095/api/auth/login
# Should return 405 Method Not Allowed (because we didn't POST)
# or 401 Unauthorized - both are GOOD (server is responding)
# Test WebSocket endpoint
websocat ws://localhost:8095/ws
```
### 4. Check Firewall
```bash
# Check if port 443 is open
sudo ufw status
sudo iptables -L -n | grep 443
# Test from outside
telnet musicassistant-app.hanold.online 443
```
## Common Reverse Proxy Issues
### Issue 1: WebSocket Upgrade Not Working
**Symptoms:**
- HTTP works but WebSocket fails
- Connection established but immediately closes
- Error: "WebSocket upgrade failed"
**Solution:**
Ensure these headers are set:
```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
### Issue 2: SSL Certificate Mismatch
**Symptoms:**
- "Certificate not valid for domain"
- SSL errors in browser/app
**Solution:**
```bash
# Verify certificate matches domain
openssl s_client -connect musicassistant-app.hanold.online:443 -servername musicassistant-app.hanold.online
# Check certificate details
echo | openssl s_client -connect musicassistant-app.hanold.online:443 2>/dev/null | openssl x509 -noout -subject -dates
```
### Issue 3: Connection Timeout
**Symptoms:**
- Connection starts but times out
- Works for a while then disconnects
**Solution:**
Increase timeouts in nginx:
```nginx
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
```
### Issue 4: Port Not Specified
**Symptoms:**
- Works with `https://domain:8095` but not `https://domain`
**Solution:**
If your reverse proxy is on port 443, users should access without port:
-`https://musicassistant-app.hanold.online`
-`https://musicassistant-app.hanold.online:8095`
Update app to use port 443 (default HTTPS port):
```swift
// In LoginView, change default:
@State private var serverURL = "https://"
```
### Issue 5: Backend Not Responding
**Symptoms:**
- 502 Bad Gateway
- 504 Gateway Timeout
**Solution:**
```bash
# Check Music Assistant is running
systemctl status music-assistant
# Check it's listening
netstat -tlnp | grep 8095
# Check logs
journalctl -u music-assistant -f
```
## Testing Your Setup
### Step 1: Browser Test
Open in Safari/Chrome:
```
https://musicassistant-app.hanold.online
```
Should see Music Assistant web interface or API response.
### Step 2: API Test
```bash
curl -X POST https://musicassistant-app.hanold.online/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"test"}'
```
Should get 401 Unauthorized or valid response.
### Step 3: WebSocket Test (Critical!)
```bash
# Using websocat (install: brew install websocat)
websocat wss://musicassistant-app.hanold.online/ws
```
Should connect (might require auth token).
### Step 4: iOS App Test
If the above works, the iOS app should work too.
## App Configuration for Reverse Proxy
When using a reverse proxy on standard HTTPS port (443):
### User enters:
```
https://musicassistant-app.hanold.online
```
### App should connect to:
- **REST API:** `https://musicassistant-app.hanold.online/api/auth/login`
- **WebSocket:** `wss://musicassistant-app.hanold.online/ws`
**NO PORT 8095 needed!** The reverse proxy handles that internally.
## Debugging iOS App Connection
Add more logging to see what URL is being used:
```swift
// In MAAuthManager.login()
logger.info("Login URL: \(loginURL.absoluteString)")
// In MAWebSocketClient.performConnect()
logger.info("WebSocket URL: \(wsURL.absoluteString)")
```
Check Xcode console to see exact URLs being used.
## Your Specific Setup
Based on your domain `musicassistant-app.hanold.online`, verify:
1. **DNS resolves:**
```bash
nslookup musicassistant-app.hanold.online
```
2. **HTTPS accessible:**
```bash
curl -I https://musicassistant-app.hanold.online
```
3. **Certificate valid:**
```bash
openssl s_client -connect musicassistant-app.hanold.online:443 -servername musicassistant-app.hanold.online
```
4. **WebSocket works:**
```bash
websocat wss://musicassistant-app.hanold.online/ws
```
## Recommended nginx Config for Your Domain
```nginx
server {
listen 443 ssl http2;
server_name musicassistant-app.hanold.online;
# Let's Encrypt SSL
ssl_certificate /etc/letsencrypt/live/musicassistant-app.hanold.online/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/musicassistant-app.hanold.online/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://127.0.0.1:8095;
proxy_http_version 1.1;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Standard headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
server {
listen 80;
server_name musicassistant-app.hanold.online;
return 301 https://$host$request_uri;
}
```
## Next Steps
1. Share your reverse proxy config (nginx/Caddy/etc.)
2. Run diagnostic commands above
3. Check reverse proxy logs for errors
4. Test with curl/browser before iOS app
5. If browser works but app doesn't, it's an app issue
6. If browser doesn't work, it's a server/proxy issue
@@ -0,0 +1,308 @@
# Troubleshooting: Players/Library Not Loading
## Symptom
After successful login, the Players and Library tabs show:
- Loading spinner forever
- "No Players Found"
- "Error Loading Players"
- Empty lists
## Debugging Steps
### 1. Check Connection Info
**In the app:**
1. Go to **Players** tab
2. Tap **Info icon** (️) in toolbar
3. Check:
- ✅ Server URL is correct
- ✅ "Connected" shows "Yes"
- ✅ "WebSocket" shows "Connected"
- ✅ "Status" shows "Authenticated"
### 2. Check Console Logs
**In Xcode:**
1. Run the app with console open (⌘+Shift+Y)
2. Look for these log messages:
**Good signs:**
```
🔵 PlayerListView: Starting to load players...
🔵 MAService.getPlayers: Sending 'players' command
✅ MAService.getPlayers: Received 3 players
✅ PlayerListView: Successfully loaded 3 players
```
**Bad signs:**
```
❌ MAService.getPlayers: Error - notConnected
❌ PlayerListView: Failed to load players: Not connected to server
```
**Or:**
```
❌ WebSocket receive error: The operation couldn't be completed
❌ Failed to decode response
```
### 3. Common Causes & Solutions
#### Cause A: WebSocket Not Connected
**Symptoms:**
- Console shows: "Not connected to server"
- Connection Info shows: WebSocket = "Disconnected"
**Solution:**
```swift
// Check if WebSocket endpoint is reachable
// For reverse proxy users:
wss://musicassistant-app.hanold.online/ws
// Test in terminal:
websocat wss://musicassistant-app.hanold.online/ws
```
**Reverse Proxy Fix:**
Ensure nginx has WebSocket support:
```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
#### Cause B: API Endpoint Wrong
**Symptoms:**
- Login works but nothing else loads
- Console shows: "Invalid URL" or "404"
**Solution:**
Check server URL format:
-`https://musicassistant-app.hanold.online` (no port if using reverse proxy)
-`http://192.168.1.100:8095` (with port if direct)
-`https://musicassistant-app.hanold.online:8095` (wrong if using reverse proxy on 443)
#### Cause C: Token Invalid
**Symptoms:**
- Login succeeds but API calls fail
- Console shows: "401 Unauthorized"
**Solution:**
1. Generate new long-lived token
2. In app: Settings → Disconnect
3. Login again with new token
#### Cause D: Music Assistant Commands Changed
**Symptoms:**
- "Command not found" errors
- Decoding errors
**Solution:**
- Update Music Assistant server to latest version
- Check API compatibility (Server v2.7+ required)
#### Cause E: CORS or Security Issues
**Symptoms:**
- WebSocket connects but commands fail
- Mixed content warnings
**Solution:**
- Ensure reverse proxy allows WebSocket
- Check HTTPS is properly configured
- Verify no CORS blocking
### 4. Test WebSocket Directly
**Terminal test:**
```bash
# Install websocat
brew install websocat
# Test WebSocket connection
websocat wss://musicassistant-app.hanold.online/ws
# Should see connection open
# Press Ctrl+C to close
```
**With authentication:**
```bash
# You'll need to send auth first
# This is complex - use app debugging instead
```
### 5. Test API Endpoints
**Test REST API:**
```bash
# Get players (won't work without WebSocket but tests connectivity)
curl -X POST https://musicassistant-app.hanold.online/api/players \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json"
```
### 6. Enable Detailed Logging
The app now includes print statements for debugging.
**What to look for in console:**
**1. Connection Phase:**
```
[INFO] Connecting to Music Assistant
[INFO] Connecting to wss://...
[INFO] Connected successfully
```
**2. Loading Phase:**
```
🔵 PlayerListView: Starting to load players...
🔵 MAService.getPlayers: Sending 'players' command
[DEBUG] Sending command: players (ID: ABC-123)
```
**3. Response Phase:**
```
[DEBUG] Received event: player_updated
✅ MAService.getPlayers: Received 3 players
```
**4. Error Messages:**
```
❌ WebSocket receive error: ...
❌ Failed to decode response: ...
❌ Request timeout
```
### 7. Check Music Assistant Server
**On the server:**
```bash
# Check Music Assistant is running
systemctl status music-assistant
# Check logs
journalctl -u music-assistant -f
# Look for:
# - WebSocket connection attempts
# - Authentication success/failure
# - Command processing
# - Errors
```
**Expected in server logs:**
```
[INFO] WebSocket connection from 192.168.1.X
[INFO] Client authenticated: user@example.com
[DEBUG] Received command: players
[DEBUG] Sent response: players (3 items)
```
## Quick Fixes
### Fix 1: Reconnect
**In app:**
1. Players tab → Info icon → **Reconnect**
2. Or: Settings → **Disconnect** → Login again
### Fix 2: Clear Cache
**In Xcode:**
1. Product → Clean Build Folder
2. Delete app from simulator/device
3. Rebuild and run
### Fix 3: Check WebSocket in nginx
**Add logging:**
```nginx
location /ws {
access_log /var/log/nginx/websocket.log;
error_log /var/log/nginx/websocket_error.log;
proxy_pass http://127.0.0.1:8095;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
**Check logs:**
```bash
tail -f /var/log/nginx/websocket_error.log
```
### Fix 4: Test with Browser
**Open browser:**
```
https://musicassistant-app.hanold.online
```
**If web interface works:**
→ Problem is in iOS app
**If web interface doesn't work:**
→ Problem is server/proxy configuration
## iOS-Specific Issues
### Issue: App Timeout
**Cause:** iOS background timeout (30 seconds)
**Solution:**
Server must respond quickly. Check:
- Music Assistant not overloaded
- Database queries fast
- Network latency low
### Issue: App Suspension
**Cause:** App goes to background
**Solution:**
- App reconnects automatically
- Pull to refresh when returning
### Issue: SSL Certificate
**Cause:** Self-signed certificate
**Solution:**
Add ATS exception (see HTTPS-Troubleshooting.md)
## Still Not Working?
**Collect this info:**
1. **Server URL:** ________________
2. **Music Assistant Version:** ________________
3. **Reverse Proxy:** Yes/No
4. **Console Output:** (paste logs)
5. **Connection Info Screenshot**
6. **Server Logs:** (paste relevant lines)
**Debug checklist:**
- [ ] Browser can access https://YOUR_SERVER
- [ ] WebSocket test with websocat works
- [ ] Server logs show WebSocket connections
- [ ] Token is valid (not expired/revoked)
- [ ] Reverse proxy has WebSocket support
- [ ] Console shows "Connected successfully"
- [ ] Music Assistant has configured players
- [ ] Network connectivity is good
**If all checks pass but still fails:**
→ Likely a bug in the app or API incompatibility
→ Check Music Assistant version is 2.7+
→ Try with official Music Assistant mobile app to compare
@@ -0,0 +1,20 @@
//
// AudioPlayerEnvironment.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
// Environment key for audio player
private struct AudioPlayerKey: EnvironmentKey {
static let defaultValue: MAAudioPlayer? = nil
}
extension EnvironmentValues {
var audioPlayer: MAAudioPlayer? {
get { self[AudioPlayerKey.self] }
set { self[AudioPlayerKey.self] = newValue }
}
}
+75
View File
@@ -0,0 +1,75 @@
# Info.plist Configuration for Background Audio
Add the following to your `Info.plist` file to enable background audio playback:
## Required Background Modes
```xml
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
```
## Full Info.plist Example
```xml
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
```
## How to Add in Xcode
1. Open your project in Xcode
2. Select your app target
3. Go to "Signing & Capabilities" tab
4. Click "+ Capability"
5. Select "Background Modes"
6. Check "Audio, AirPlay, and Picture in Picture"
This will automatically add the required entry to Info.plist.
@@ -0,0 +1,20 @@
//
// Mobile_Music_AssistantApp.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
@main
struct Mobile_Music_AssistantApp: App {
@State private var service = MAService()
var body: some Scene {
WindowGroup {
RootView()
.environment(service)
}
}
}
+299
View File
@@ -0,0 +1,299 @@
//
// MAModels.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import Foundation
// MARK: - Player Models
struct MAPlayer: Codable, Identifiable, Hashable {
let playerId: String
let name: String
let state: PlayerState
let currentItem: MAQueueItem?
let volume: Int
let powered: Bool
let available: Bool
var id: String { playerId }
enum CodingKeys: String, CodingKey {
case playerId = "player_id"
case name
case state
case currentItem = "current_item"
case volume = "volume_level"
case powered
case available
}
}
enum PlayerState: String, Codable {
case playing
case paused
case idle
case off
}
// MARK: - Queue Models
struct MAQueueItem: Codable, Identifiable, Hashable {
let queueItemId: String
let mediaItem: MAMediaItem?
let name: String
let duration: Int?
let streamDetails: MAStreamDetails?
var id: String { queueItemId }
enum CodingKeys: String, CodingKey {
case queueItemId = "queue_item_id"
case mediaItem = "media_item"
case name
case duration
case streamDetails = "stream_details"
}
}
struct MAStreamDetails: Codable, Hashable {
let providerId: String
let itemId: String
let audioFormat: MAAudioFormat?
enum CodingKeys: String, CodingKey {
case providerId = "provider"
case itemId = "item_id"
case audioFormat = "audio_format"
}
}
struct MAAudioFormat: Codable, Hashable {
let contentType: String
let sampleRate: Int?
let bitDepth: Int?
enum CodingKeys: String, CodingKey {
case contentType = "content_type"
case sampleRate = "sample_rate"
case bitDepth = "bit_depth"
}
}
// MARK: - Media Models
struct MAMediaItem: Codable, Identifiable, Hashable {
let uri: String
let name: String
let mediaType: MediaType
let artists: [MAArtist]?
let album: MAAlbum?
let imageUrl: String?
let duration: Int?
var id: String { uri }
enum CodingKeys: String, CodingKey {
case uri
case name
case mediaType = "media_type"
case artists
case album
case imageUrl = "image"
case duration
}
}
enum MediaType: String, Codable {
case track
case album
case artist
case playlist
case radio
}
struct MAArtist: Codable, Identifiable, Hashable {
let uri: String
let name: String
let imageUrl: String?
let sortName: String?
let musicbrainzId: String?
var id: String { uri }
enum CodingKeys: String, CodingKey {
case uri
case name
case imageUrl = "image"
case sortName = "sort_name"
case musicbrainzId = "musicbrainz_id"
}
}
struct MAAlbum: Codable, Identifiable, Hashable {
let uri: String
let name: String
let artists: [MAArtist]?
let imageUrl: String?
let year: Int?
var id: String { uri }
enum CodingKeys: String, CodingKey {
case uri
case name
case artists
case imageUrl = "image"
case year
}
}
struct MAPlaylist: Codable, Identifiable, Hashable {
let uri: String
let name: String
let owner: String?
let imageUrl: String?
let isEditable: Bool
var id: String { uri }
enum CodingKeys: String, CodingKey {
case uri
case name
case owner
case imageUrl = "image"
case isEditable = "is_editable"
}
}
// MARK: - WebSocket Protocol Models
struct MACommand: Encodable {
let messageId: String
let command: String
let args: [String: AnyCodable]?
enum CodingKeys: String, CodingKey {
case messageId = "message_id"
case command
case args
}
}
struct MAResponse: Decodable {
let messageId: String?
let result: AnyCodable?
let errorCode: String?
let errorMessage: String?
enum CodingKeys: String, CodingKey {
case messageId = "message_id"
case result
case errorCode = "error_code"
case errorMessage = "error"
}
}
struct MAEvent: Decodable {
let event: String
let data: AnyCodable?
}
// MARK: - Auth Models
struct MALoginRequest: Encodable {
let username: String
let password: String
}
struct MALoginResponse: Decodable {
let accessToken: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
}
}
// MARK: - AnyCodable Helper
/// Helper to handle dynamic JSON values
struct AnyCodable: Codable, Hashable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
value = NSNull()
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let string = try? container.decode(String.self) {
value = string
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map { $0.value }
} else if let dict = try? container.decode([String: AnyCodable].self) {
value = dict.mapValues { $0.value }
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unable to decode value"
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case is NSNull:
try container.encodeNil()
case let bool as Bool:
try container.encode(bool)
case let int as Int:
try container.encode(int)
case let double as Double:
try container.encode(double)
case let string as String:
try container.encode(string)
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
default:
throw EncodingError.invalidValue(
value,
EncodingError.Context(
codingPath: container.codingPath,
debugDescription: "Unable to encode value"
)
)
}
}
static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
// Simple comparison - extend as needed
return String(describing: lhs.value) == String(describing: rhs.value)
}
func hash(into hasher: inout Hasher) {
hasher.combine(String(describing: value))
}
}
extension AnyCodable {
/// Decode the wrapped value to a specific type
func decode<T: Decodable>(as type: T.Type) throws -> T {
let data = try JSONEncoder().encode(self)
return try JSONDecoder().decode(T.self, from: data)
}
}
@@ -0,0 +1,444 @@
//
// MAAudioPlayer.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import Foundation
import AVFoundation
import MediaPlayer
import OSLog
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "AudioPlayer")
/// Audio player for local playback on iPhone
@Observable
final class MAAudioPlayer: NSObject {
// MARK: - Properties
private let service: MAService
private var player: AVPlayer?
private(set) var currentItem: MAQueueItem?
private var timeObserver: Any?
// Playback state
private(set) var isPlaying = false
private(set) var currentTime: TimeInterval = 0
private(set) var duration: TimeInterval = 0
// Volume
var volume: Float {
get { AVAudioSession.sharedInstance().outputVolume }
set {
// Volume can only be changed via system controls on iOS
// This is here for compatibility
}
}
// MARK: - Initialization
init(service: MAService) {
self.service = service
super.init()
setupAudioSession()
setupRemoteCommands()
setupNotifications()
}
deinit {
cleanupPlayer()
NotificationCenter.default.removeObserver(self)
}
// MARK: - Audio Session Setup
private func setupAudioSession() {
let audioSession = AVAudioSession.sharedInstance()
do {
// Configure for playback
try audioSession.setCategory(
.playback,
mode: .default,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
try audioSession.setActive(true)
logger.info("Audio session configured")
} catch {
logger.error("Failed to configure audio session: \(error.localizedDescription)")
}
}
// MARK: - Remote Commands (Lock Screen Controls)
private func setupRemoteCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
// Play command
commandCenter.playCommand.addTarget { [weak self] _ in
self?.play()
return .success
}
// Pause command
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.pause()
return .success
}
// Stop command
commandCenter.stopCommand.addTarget { [weak self] _ in
self?.stop()
return .success
}
// Next track
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
Task {
await self?.nextTrack()
}
return .success
}
// Previous track
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
Task {
await self?.previousTrack()
}
return .success
}
// Change playback position
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
self?.seek(to: event.positionTime)
return .success
}
logger.info("Remote commands configured")
}
// MARK: - Notifications
private func setupNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance()
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance()
)
}
@objc private func handleInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began (e.g., phone call)
pause()
logger.info("Audio interrupted - pausing")
case .ended:
// Interruption ended
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else {
return
}
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
play()
logger.info("Audio interruption ended - resuming")
}
@unknown default:
break
}
}
@objc private func handleRouteChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
return
}
switch reason {
case .oldDeviceUnavailable:
// Headphones unplugged
pause()
logger.info("Audio route changed - pausing")
default:
break
}
}
// MARK: - Playback Control
/// Play current item or resume
func play() {
guard let player else { return }
player.play()
isPlaying = true
updateNowPlayingInfo()
logger.info("Playing")
}
/// Pause playback
func pause() {
guard let player else { return }
player.pause()
isPlaying = false
updateNowPlayingInfo()
logger.info("Paused")
}
/// Stop playback
func stop() {
cleanupPlayer()
isPlaying = false
currentItem = nil
clearNowPlayingInfo()
logger.info("Stopped")
}
/// Next track
func nextTrack() async {
logger.info("Next track requested")
// TODO: Get next item from queue
// For now, just stop
stop()
}
/// Previous track
func previousTrack() async {
logger.info("Previous track requested")
// TODO: Get previous item from queue
// For now, restart current track
seek(to: 0)
}
/// Seek to position
func seek(to time: TimeInterval) {
guard let player else { return }
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
player.seek(to: cmTime) { [weak self] _ in
self?.updateNowPlayingInfo()
}
logger.info("Seeking to \(time)s")
}
// MARK: - Load & Play Media
/// Play a queue item from a specific player's queue
func playQueueItem(_ item: MAQueueItem, queueId: String) async throws {
logger.info("Playing queue item: \(item.name) from queue \(queueId)")
// Get stream URL from server
let streamURL = try await service.getStreamURL(
queueId: queueId,
queueItemId: item.queueItemId
)
logger.info("Got stream URL: \(streamURL.absoluteString)")
// Load and play
await loadAndPlay(item: item, streamURL: streamURL)
}
/// Play a media item by URI (adds to queue and plays)
func playMediaItem(uri: String, queueId: String) async throws {
logger.info("Playing media item: \(uri)")
// First, tell the server to add this to the queue
try await service.playMedia(playerId: queueId, uri: uri)
// Wait a bit for the queue to update
try await Task.sleep(for: .milliseconds(500))
// Get the updated queue
let queue = try await service.getQueue(playerId: queueId)
// Find the item we just added (should be first or currently playing)
guard let item = queue.first else {
throw MAWebSocketClient.ClientError.serverError("Queue is empty after adding item")
}
// Get stream URL and play
try await playQueueItem(item, queueId: queueId)
}
/// Load and play a media item with stream URL
private func loadAndPlay(item: MAQueueItem, streamURL: URL) async {
await MainActor.run {
logger.info("Loading media: \(item.name)")
cleanupPlayer()
currentItem = item
// Build authenticated request if needed
var headers: [String: String] = [:]
if let token = service.authManager.currentToken {
headers["Authorization"] = "Bearer \(token)"
}
// Create asset with auth headers
let asset = AVURLAsset(url: streamURL, options: [
"AVURLAssetHTTPHeaderFieldsKey": headers
])
// Create player item
let playerItem = AVPlayerItem(asset: asset)
// Create player
player = AVPlayer(playerItem: playerItem)
// Observe playback time
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = player?.addPeriodicTimeObserver(
forInterval: interval,
queue: .main
) { [weak self] time in
self?.updatePlaybackTime(time)
}
// Observe player status
NotificationCenter.default.addObserver(
self,
selector: #selector(playerDidFinishPlaying),
name: .AVPlayerItemDidPlayToEndTime,
object: playerItem
)
// Get duration (async)
Task {
let duration = try? await asset.load(.duration)
if let duration, duration.seconds.isFinite {
await MainActor.run {
self.duration = duration.seconds
self.updateNowPlayingInfo()
}
}
}
// Start playing
play()
}
}
@objc private func playerDidFinishPlaying() {
logger.info("Player finished playing")
// Auto-play next track
Task {
await nextTrack()
}
}
private func updatePlaybackTime(_ time: CMTime) {
let seconds = time.seconds
guard seconds.isFinite else { return }
currentTime = seconds
updateNowPlayingInfo()
}
private func cleanupPlayer() {
if let timeObserver {
player?.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
player?.pause()
player = nil
currentTime = 0
duration = 0
}
// MARK: - Now Playing Info (Lock Screen)
private func updateNowPlayingInfo() {
guard let item = currentItem else {
clearNowPlayingInfo()
return
}
var nowPlayingInfo: [String: Any] = [:]
// Track info
nowPlayingInfo[MPMediaItemPropertyTitle] = item.name
if let mediaItem = item.mediaItem {
if let artists = mediaItem.artists, !artists.isEmpty {
nowPlayingInfo[MPMediaItemPropertyArtist] = artists.map { $0.name }.joined(separator: ", ")
}
if let album = mediaItem.album {
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = album.name
}
}
// Duration & position
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
// Artwork (async load)
if let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl,
let coverURL = service.imageProxyURL(path: imageUrl, size: 512) {
Task {
await loadArtwork(from: coverURL, into: &nowPlayingInfo)
}
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
private func loadArtwork(from url: URL, into info: inout [String: Any]) async {
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let image = UIImage(data: data) {
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
await MainActor.run {
var updatedInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
updatedInfo[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = updatedInfo
}
}
} catch {
logger.error("Failed to load artwork: \(error.localizedDescription)")
}
}
private func clearNowPlayingInfo() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
}
}
@@ -0,0 +1,285 @@
//
// MAAuthManager.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import Foundation
import Security
import OSLog
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Auth")
/// Manages authentication with Music Assistant server
@Observable
final class MAAuthManager {
enum AuthError: LocalizedError {
case invalidCredentials
case networkError(Error)
case keychainError(OSStatus)
case noStoredCredentials
case domainNotFound
case connectionTimeout
case sslError
var errorDescription: String? {
switch self {
case .invalidCredentials:
return "Invalid username or password"
case .networkError(let error):
// Provide more specific error messages
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet:
return "No internet connection. Please check your network."
case .cannotFindHost:
return "Cannot find server. Check the URL: The domain might not exist or is unreachable."
case .cannotConnectToHost:
return "Cannot connect to server. The server might be offline or unreachable."
case .networkConnectionLost:
return "Network connection lost. Please try again."
case .timedOut:
return "Connection timed out. The server is taking too long to respond."
case .dnsLookupFailed:
return "DNS lookup failed. Cannot resolve domain name. Check the URL."
case .secureConnectionFailed:
return "SSL/TLS connection failed. Check server certificate or use HTTP."
case .serverCertificateUntrusted:
return "Server certificate is not trusted. Add ATS exception to Info.plist."
case .badURL:
return "Invalid URL format. Check the server URL."
default:
return "Network error: \(urlError.localizedDescription)"
}
}
return "Network error: \(error.localizedDescription)"
case .keychainError(let status):
return "Keychain error: \(status)"
case .noStoredCredentials:
return "No stored credentials found"
case .domainNotFound:
return "Domain not found. Check the server URL."
case .connectionTimeout:
return "Connection timeout. Server is not responding."
case .sslError:
return "SSL certificate error. Try HTTP or add ATS exception."
}
}
}
// MARK: - Properties
private(set) var isAuthenticated = false
private(set) var currentToken: String?
private(set) var serverURL: URL?
private let keychainService = "com.musicassistant.mobile"
private let tokenKey = "auth_token"
private let serverURLKey = "server_url"
// UserDefaults for server URL (not sensitive)
private let defaults = UserDefaults.standard
// MARK: - Initialization
init() {
// Try to load saved credentials
loadSavedCredentials()
}
// MARK: - Authentication
/// Login to Music Assistant server
func login(serverURL: URL, username: String, password: String) async throws -> String {
logger.info("Attempting login to \(serverURL.absoluteString)")
// Build login URL
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
components.path = "/api/auth/login"
guard let loginURL = components.url else {
throw AuthError.invalidCredentials
}
// Create request
var request = URLRequest(url: loginURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 30
let loginRequest = MALoginRequest(username: username, password: password)
request.httpBody = try JSONEncoder().encode(loginRequest)
// Send request with better error handling
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logger.error("Invalid response type")
throw AuthError.networkError(URLError(.badServerResponse))
}
logger.info("Login response status: \(httpResponse.statusCode)")
// Handle different status codes
switch httpResponse.statusCode {
case 200:
// Success - decode response
do {
let loginResponse = try JSONDecoder().decode(MALoginResponse.self, from: data)
logger.info("Login successful - received short-lived token")
return loginResponse.accessToken
} catch {
logger.error("Failed to decode login response: \(error.localizedDescription)")
throw AuthError.networkError(error)
}
case 401:
logger.error("Login failed - invalid credentials")
throw AuthError.invalidCredentials
default:
logger.error("Login failed with status \(httpResponse.statusCode)")
if let errorString = String(data: data, encoding: .utf8) {
logger.error("Error response: \(errorString)")
}
throw AuthError.networkError(URLError(.badServerResponse))
}
} catch let error as AuthError {
throw error
} catch {
logger.error("Login network error: \(error.localizedDescription)")
throw AuthError.networkError(error)
}
}
/// Save token directly (for pre-generated long-lived tokens)
func saveToken(serverURL: URL, token: String) throws {
logger.info("Saving long-lived token")
print("🔵 MAAuthManager.saveToken: Saving token for \(serverURL.absoluteString)")
try saveCredentials(serverURL: serverURL, token: token)
self.serverURL = serverURL
self.currentToken = token
self.isAuthenticated = true
print("✅ MAAuthManager.saveToken: Token saved successfully")
logger.info("Long-lived token saved successfully")
}
/// Logout and clear credentials
func logout() {
logger.info("Logging out")
deleteCredentials()
self.currentToken = nil
self.serverURL = nil
self.isAuthenticated = false
}
// MARK: - Credential Storage
private func loadSavedCredentials() {
// Load server URL from UserDefaults
if let urlString = defaults.string(forKey: serverURLKey),
let url = URL(string: urlString) {
self.serverURL = url
}
// Load token from Keychain
if let token = loadTokenFromKeychain() {
self.currentToken = token
self.isAuthenticated = true
logger.info("Loaded saved credentials")
}
}
private func saveCredentials(serverURL: URL, token: String) throws {
// Save server URL to UserDefaults
defaults.set(serverURL.absoluteString, forKey: serverURLKey)
// Save token to Keychain
try saveTokenToKeychain(token)
}
private func deleteCredentials() {
// Remove from UserDefaults
defaults.removeObject(forKey: serverURLKey)
// Remove from Keychain
deleteTokenFromKeychain()
}
// MARK: - Keychain Operations
private func saveTokenToKeychain(_ token: String) throws {
guard let tokenData = token.data(using: .utf8) else {
throw AuthError.keychainError(errSecParam)
}
// Delete existing item first
deleteTokenFromKeychain()
// Add new item
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: tokenKey,
kSecValueData as String: tokenData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
logger.error("Failed to save token to Keychain: \(status)")
throw AuthError.keychainError(status)
}
logger.debug("Token saved to Keychain")
}
private func loadTokenFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: tokenKey,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8) else {
if status != errSecItemNotFound {
logger.error("Failed to load token from Keychain: \(status)")
}
return nil
}
logger.debug("Token loaded from Keychain")
return token
}
private func deleteTokenFromKeychain() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: tokenKey
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess {
logger.debug("Token deleted from Keychain")
} else if status != errSecItemNotFound {
logger.error("Failed to delete token from Keychain: \(status)")
}
}
}
@@ -0,0 +1,217 @@
//
// MALibraryManager.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import Foundation
import OSLog
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Library")
/// Manages library data and caching
@Observable
final class MALibraryManager {
// MARK: - Properties
private weak var service: MAService?
// Cache
private(set) var artists: [MAArtist] = []
private(set) var albums: [MAAlbum] = []
private(set) var playlists: [MAPlaylist] = []
// Pagination
private var artistsOffset = 0
private var albumsOffset = 0
private var hasMoreArtists = true
private var hasMoreAlbums = true
private let pageSize = 50
// Loading states
private(set) var isLoadingArtists = false
private(set) var isLoadingAlbums = false
private(set) var isLoadingPlaylists = false
// MARK: - Initialization
init(service: MAService?) {
self.service = service
}
func setService(_ service: MAService) {
self.service = service
}
// MARK: - Artists
/// Load initial artists
func loadArtists(refresh: Bool = false) async throws {
guard !isLoadingArtists else { return }
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
if refresh {
artistsOffset = 0
hasMoreArtists = true
await MainActor.run {
self.artists = []
}
}
guard hasMoreArtists else { return }
isLoadingArtists = true
defer { isLoadingArtists = false }
logger.info("Loading artists (offset: \(self.artistsOffset))")
do {
let newArtists = try await service.getArtists(
limit: pageSize,
offset: artistsOffset
)
await MainActor.run {
if refresh {
self.artists = newArtists
} else {
self.artists.append(contentsOf: newArtists)
}
self.artistsOffset += newArtists.count
self.hasMoreArtists = newArtists.count >= self.pageSize
logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)")
}
} catch {
logger.error("Failed to load artists: \(error.localizedDescription)")
throw error
}
}
/// Load more artists (pagination)
func loadMoreArtistsIfNeeded(currentItem: MAArtist?) async throws {
guard let currentItem else { return }
let thresholdIndex = artists.index(artists.endIndex, offsetBy: -10)
if let itemIndex = artists.firstIndex(where: { $0.id == currentItem.id }),
itemIndex >= thresholdIndex {
try await loadArtists(refresh: false)
}
}
// MARK: - Albums
/// Load initial albums
func loadAlbums(refresh: Bool = false) async throws {
guard !isLoadingAlbums else { return }
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
if refresh {
albumsOffset = 0
hasMoreAlbums = true
await MainActor.run {
self.albums = []
}
}
guard hasMoreAlbums else { return }
isLoadingAlbums = true
defer { isLoadingAlbums = false }
logger.info("Loading albums (offset: \(self.albumsOffset))")
do {
let newAlbums = try await service.getAlbums(
limit: pageSize,
offset: albumsOffset
)
await MainActor.run {
if refresh {
self.albums = newAlbums
} else {
self.albums.append(contentsOf: newAlbums)
}
self.albumsOffset += newAlbums.count
self.hasMoreAlbums = newAlbums.count >= self.pageSize
logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)")
}
} catch {
logger.error("Failed to load albums: \(error.localizedDescription)")
throw error
}
}
/// Load more albums (pagination)
func loadMoreAlbumsIfNeeded(currentItem: MAAlbum?) async throws {
guard let currentItem else { return }
let thresholdIndex = albums.index(albums.endIndex, offsetBy: -10)
if let itemIndex = albums.firstIndex(where: { $0.id == currentItem.id }),
itemIndex >= thresholdIndex {
try await loadAlbums(refresh: false)
}
}
// MARK: - Playlists
/// Load playlists
func loadPlaylists(refresh: Bool = false) async throws {
guard !isLoadingPlaylists else { return }
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
isLoadingPlaylists = true
defer { isLoadingPlaylists = false }
logger.info("Loading playlists")
do {
let loadedPlaylists = try await service.getPlaylists()
await MainActor.run {
self.playlists = loadedPlaylists
logger.info("Loaded \(loadedPlaylists.count) playlists")
}
} catch {
logger.error("Failed to load playlists: \(error.localizedDescription)")
throw error
}
}
// MARK: - Album Tracks
/// Get tracks for an album
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
logger.info("Loading tracks for album \(albumUri)")
return try await service.getAlbumTracks(albumUri: albumUri)
}
// MARK: - Search
/// Search library
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
guard !query.isEmpty else { return [] }
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
logger.info("Searching for '\(query)'")
return try await service.search(query: query, mediaTypes: mediaTypes)
}
}
@@ -0,0 +1,205 @@
//
// MAPlayerManager.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import Foundation
import OSLog
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "PlayerManager")
/// Manages player state and real-time updates
@Observable
final class MAPlayerManager {
// MARK: - Properties
private(set) var players: [String: MAPlayer] = [:]
private(set) var queues: [String: [MAQueueItem]] = [:]
private weak var service: MAService?
private var eventTask: Task<Void, Never>?
// MARK: - Initialization
init(service: MAService?) {
self.service = service
}
func setService(_ service: MAService) {
self.service = service
}
deinit {
stopListening()
}
// MARK: - Event Listening
/// Start listening to player events
func startListening() {
guard eventTask == nil, let service else { return }
logger.info("Starting event listener")
eventTask = Task {
for await event in service.webSocketClient.eventStream {
await handleEvent(event)
}
}
}
/// Stop listening to events
func stopListening() {
logger.info("Stopping event listener")
eventTask?.cancel()
eventTask = nil
}
private func handleEvent(_ event: MAEvent) async {
logger.debug("Handling event: \(event.event)")
switch event.event {
case "player_updated":
await handlePlayerUpdated(event)
case "queue_updated":
await handleQueueUpdated(event)
case "queue_items_updated":
await handleQueueItemsUpdated(event)
default:
logger.debug("Unhandled event: \(event.event)")
}
}
private func handlePlayerUpdated(_ event: MAEvent) async {
guard let data = event.data else { return }
do {
let player = try data.decode(as: MAPlayer.self)
await MainActor.run {
players[player.playerId] = player
logger.debug("Updated player: \(player.name) - \(player.state.rawValue)")
}
} catch {
logger.error("Failed to decode player update: \(error.localizedDescription)")
}
}
private func handleQueueUpdated(_ event: MAEvent) async {
guard let data = event.data,
let dict = data.value as? [String: Any],
let queueId = dict["queue_id"] as? String else {
return
}
// Reload queue for this player
guard let service else { return }
do {
let items = try await service.getQueue(playerId: queueId)
await MainActor.run {
queues[queueId] = items
logger.debug("Updated queue for player \(queueId): \(items.count) items")
}
} catch {
logger.error("Failed to reload queue: \(error.localizedDescription)")
}
}
private func handleQueueItemsUpdated(_ event: MAEvent) async {
// Similar to queue_updated
await handleQueueUpdated(event)
}
// MARK: - Data Loading
/// Load all players
func loadPlayers() async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
logger.info("Loading players")
let playerList = try await service.getPlayers()
await MainActor.run {
players = Dictionary(uniqueKeysWithValues: playerList.map { ($0.playerId, $0) })
}
}
/// Load queue for specific player
func loadQueue(playerId: String) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
logger.info("Loading queue for player \(playerId)")
let items = try await service.getQueue(playerId: playerId)
await MainActor.run {
queues[playerId] = items
}
}
// MARK: - Player Control
func play(playerId: String) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.play(playerId: playerId)
}
func pause(playerId: String) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.pause(playerId: playerId)
}
func stop(playerId: String) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.stop(playerId: playerId)
}
func nextTrack(playerId: String) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.nextTrack(playerId: playerId)
}
func previousTrack(playerId: String) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.previousTrack(playerId: playerId)
}
func setVolume(playerId: String, level: Int) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.setVolume(playerId: playerId, level: level)
}
func playMedia(playerId: String, uri: String) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.playMedia(playerId: playerId, uri: uri)
}
func playIndex(playerId: String, index: Int) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.playIndex(playerId: playerId, index: index)
}
}
@@ -0,0 +1,331 @@
//
// MAService.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import Foundation
import OSLog
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Service")
/// High-level service for Music Assistant API
@Observable
final class MAService {
// MARK: - Properties
let authManager: MAAuthManager
let webSocketClient: MAWebSocketClient
let playerManager: MAPlayerManager
let libraryManager: MALibraryManager
private(set) var isConnected = false
// MARK: - Initialization
init() {
// Initialize simple properties first
self.authManager = MAAuthManager()
self.webSocketClient = MAWebSocketClient()
// Create a temporary service reference
let tempPlayerManager = MAPlayerManager(service: nil)
let tempLibraryManager = MALibraryManager(service: nil)
self.playerManager = tempPlayerManager
self.libraryManager = tempLibraryManager
// Now set the service reference
tempPlayerManager.setService(self)
tempLibraryManager.setService(self)
}
// MARK: - Connection
/// Connect to Music Assistant server using saved credentials
func connectWithSavedCredentials() async throws {
guard authManager.isAuthenticated,
let serverURL = authManager.serverURL,
let token = authManager.currentToken else {
throw MAAuthManager.AuthError.noStoredCredentials
}
try await connect(serverURL: serverURL, token: token)
}
/// Connect to server with explicit credentials
func connect(serverURL: URL, token: String) async throws {
logger.info("Connecting to Music Assistant")
try await webSocketClient.connect(serverURL: serverURL, authToken: token)
isConnected = true
}
/// Disconnect from server
func disconnect() {
logger.info("Disconnecting from Music Assistant")
webSocketClient.disconnect()
isConnected = false
}
// MARK: - Players
/// Get all players
func getPlayers() async throws -> [MAPlayer] {
logger.debug("Fetching players")
return try await webSocketClient.sendCommand(
"players",
resultType: [MAPlayer].self
)
}
/// Play on a player
func play(playerId: String) async throws {
logger.debug("Playing on player \(playerId)")
_ = try await webSocketClient.sendCommand(
"players/cmd/play",
args: ["player_id": playerId]
)
}
/// Pause a player
func pause(playerId: String) async throws {
logger.debug("Pausing player \(playerId)")
_ = try await webSocketClient.sendCommand(
"players/cmd/pause",
args: ["player_id": playerId]
)
}
/// Stop a player
func stop(playerId: String) async throws {
logger.debug("Stopping player \(playerId)")
_ = try await webSocketClient.sendCommand(
"players/cmd/stop",
args: ["player_id": playerId]
)
}
/// Next track
func nextTrack(playerId: String) async throws {
logger.debug("Next track on player \(playerId)")
_ = try await webSocketClient.sendCommand(
"players/cmd/next",
args: ["player_id": playerId]
)
}
/// Previous track
func previousTrack(playerId: String) async throws {
logger.debug("Previous track on player \(playerId)")
_ = try await webSocketClient.sendCommand(
"players/cmd/previous",
args: ["player_id": playerId]
)
}
/// Set volume (0-100)
func setVolume(playerId: String, level: Int) async throws {
let clampedLevel = max(0, min(100, level))
logger.debug("Setting volume to \(clampedLevel) on player \(playerId)")
_ = try await webSocketClient.sendCommand(
"players/cmd/volume_set",
args: [
"player_id": playerId,
"volume_level": clampedLevel
]
)
}
// MARK: - Queue
/// Get player queue
func getQueue(playerId: String) async throws -> [MAQueueItem] {
logger.debug("Fetching queue for player \(playerId)")
return try await webSocketClient.sendCommand(
"player_queues/items",
args: ["queue_id": playerId],
resultType: [MAQueueItem].self
)
}
/// Play media item
func playMedia(playerId: String, uri: String) async throws {
logger.debug("Playing media \(uri) on player \(playerId)")
_ = try await webSocketClient.sendCommand(
"player_queues/cmd/play_media",
args: [
"queue_id": playerId,
"media": [uri]
]
)
}
/// Play from queue index
func playIndex(playerId: String, index: Int) async throws {
logger.debug("Playing index \(index) on player \(playerId)")
_ = try await webSocketClient.sendCommand(
"player_queues/cmd/play_index",
args: [
"queue_id": playerId,
"index": index
]
)
}
/// Move queue item
func moveQueueItem(playerId: String, fromIndex: Int, toIndex: Int) async throws {
logger.debug("Moving queue item from \(fromIndex) to \(toIndex)")
_ = try await webSocketClient.sendCommand(
"player_queues/cmd/move_item",
args: [
"queue_id": playerId,
"queue_item_id": fromIndex,
"pos_shift": toIndex - fromIndex
]
)
}
// MARK: - Library
/// Get artists (with pagination)
func getArtists(limit: Int = 50, offset: Int = 0) async throws -> [MAArtist] {
logger.debug("Fetching artists (limit: \(limit), offset: \(offset))")
return try await webSocketClient.sendCommand(
"music/artists",
args: [
"limit": limit,
"offset": offset
],
resultType: [MAArtist].self
)
}
/// Get albums (with pagination)
func getAlbums(limit: Int = 50, offset: Int = 0) async throws -> [MAAlbum] {
logger.debug("Fetching albums (limit: \(limit), offset: \(offset))")
return try await webSocketClient.sendCommand(
"music/albums",
args: [
"limit": limit,
"offset": offset
],
resultType: [MAAlbum].self
)
}
/// Get playlists
func getPlaylists() async throws -> [MAPlaylist] {
logger.debug("Fetching playlists")
return try await webSocketClient.sendCommand(
"music/playlists",
resultType: [MAPlaylist].self
)
}
/// Get album tracks
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
logger.debug("Fetching tracks for album \(albumUri)")
return try await webSocketClient.sendCommand(
"music/album_tracks",
args: ["uri": albumUri],
resultType: [MAMediaItem].self
)
}
/// Search library
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
logger.debug("Searching for '\(query)'")
var args: [String: Any] = ["search": query]
if let mediaTypes {
args["media_types"] = mediaTypes.map { $0.rawValue }
}
return try await webSocketClient.sendCommand(
"music/search",
args: args,
resultType: [MAMediaItem].self
)
}
// MARK: - Image Proxy
/// Build URL for image proxy
func imageProxyURL(path: String, size: Int = 256) -> URL? {
guard let serverURL = authManager.serverURL else { return nil }
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
components.path = "/api/image_proxy"
components.queryItems = [
URLQueryItem(name: "path", value: path),
URLQueryItem(name: "size", value: String(size))
]
return components.url
}
// MARK: - Audio Streaming
/// Get stream URL for a queue item
func getStreamURL(queueId: String, queueItemId: String) async throws -> URL {
logger.debug("Getting stream URL for queue item \(queueItemId)")
// For local player, we might need to build the URL differently
if queueId == "local_player" {
// Direct stream URL from server
guard let serverURL = authManager.serverURL else {
throw MAWebSocketClient.ClientError.serverError("No server URL configured")
}
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
components.path = "/api/stream/\(queueId)/\(queueItemId)"
guard let streamURL = components.url else {
throw MAWebSocketClient.ClientError.serverError("Failed to build stream URL")
}
return streamURL
}
let response = try await webSocketClient.sendCommand(
"player_queues/cmd/get_stream_url",
args: [
"queue_id": queueId,
"queue_item_id": queueItemId
]
)
guard let result = response.result else {
throw MAWebSocketClient.ClientError.serverError("No result in stream URL response")
}
// Try to extract URL from response
if let urlString = result.value as? String {
// Handle relative URL
if urlString.starts(with: "/") {
guard let serverURL = authManager.serverURL else {
throw MAWebSocketClient.ClientError.serverError("No server URL configured")
}
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
components.path = urlString
guard let fullURL = components.url else {
throw MAWebSocketClient.ClientError.serverError("Failed to build stream URL")
}
return fullURL
}
// Handle absolute URL
guard let url = URL(string: urlString) else {
throw MAWebSocketClient.ClientError.serverError("Invalid stream URL format: \(urlString)")
}
return url
}
throw MAWebSocketClient.ClientError.serverError("Invalid stream URL format in response")
}
}
@@ -0,0 +1,380 @@
//
// MAWebSocketClient.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import Foundation
import OSLog
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "WebSocket")
/// WebSocket client for Music Assistant server communication
@Observable
final class MAWebSocketClient {
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case reconnecting(attempt: Int)
var description: String {
switch self {
case .disconnected: return "Disconnected"
case .connecting: return "Connecting..."
case .connected: return "Connected"
case .reconnecting(let attempt): return "Reconnecting (attempt \(attempt))..."
}
}
}
enum ClientError: LocalizedError {
case notConnected
case invalidURL
case timeout
case serverError(String)
case decodingError(Error)
var errorDescription: String? {
switch self {
case .notConnected:
return "Not connected to server"
case .invalidURL:
return "Invalid server URL"
case .timeout:
return "Request timeout"
case .serverError(let message):
return "Server error: \(message)"
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
}
}
}
// MARK: - Properties
private(set) var connectionState: ConnectionState = .disconnected
private var webSocketTask: URLSessionWebSocketTask?
private let session: URLSession
// Request-Response matching
private var pendingRequests: [String: CheckedContinuation<MAResponse, Error>] = [:]
private let requestQueue = DispatchQueue(label: "com.musicassistant.requests")
// Event stream
private var eventContinuation: AsyncStream<MAEvent>.Continuation?
private(set) var eventStream: AsyncStream<MAEvent>
// Reconnection
private var reconnectTask: Task<Void, Never>?
private var shouldReconnect = false
private let maxReconnectDelay: TimeInterval = 30.0
private let initialReconnectDelay: TimeInterval = 3.0
// Configuration
private var serverURL: URL?
private var authToken: String?
// MARK: - Initialization
init() {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 300
self.session = URLSession(configuration: configuration)
// Initialize event stream
var continuation: AsyncStream<MAEvent>.Continuation?
self.eventStream = AsyncStream { cont in
continuation = cont
}
self.eventContinuation = continuation
}
deinit {
disconnect()
}
// MARK: - Connection Management
/// Connect to Music Assistant server
func connect(serverURL: URL, authToken: String?) async throws {
print("🔵 MAWebSocketClient.connect: Checking state")
guard connectionState == .disconnected else {
logger.info("Already connected or connecting")
print("⚠️ MAWebSocketClient.connect: Already connected/connecting, state = \(connectionState)")
return
}
print("🔵 MAWebSocketClient.connect: Starting connection")
print("🔵 MAWebSocketClient.connect: Server URL = \(serverURL.absoluteString)")
print("🔵 MAWebSocketClient.connect: Has auth token = \(authToken != nil)")
self.serverURL = serverURL
self.authToken = authToken
self.shouldReconnect = true
try await performConnect()
}
private func performConnect() async throws {
guard let serverURL else {
print("❌ MAWebSocketClient.performConnect: No server URL")
throw ClientError.invalidURL
}
connectionState = .connecting
logger.info("Connecting to \(serverURL.absoluteString)")
print("🔵 MAWebSocketClient.performConnect: Building WebSocket URL")
// Build WebSocket URL (ws:// or wss://)
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
let originalScheme = components.scheme
components.scheme = components.scheme == "https" ? "wss" : "ws"
components.path = "/ws"
guard let wsURL = components.url else {
print("❌ MAWebSocketClient.performConnect: Failed to build WebSocket URL")
throw ClientError.invalidURL
}
print("🔵 MAWebSocketClient.performConnect: Original scheme = \(originalScheme ?? "nil")")
print("🔵 MAWebSocketClient.performConnect: WebSocket URL = \(wsURL.absoluteString)")
var request = URLRequest(url: wsURL)
// Add auth token if available
if let authToken {
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
print("✅ MAWebSocketClient.performConnect: Authorization header added")
} else {
print("⚠️ MAWebSocketClient.performConnect: No auth token provided")
}
let task = session.webSocketTask(with: request)
self.webSocketTask = task
print("🔵 MAWebSocketClient.performConnect: Starting WebSocket task")
task.resume()
// Start listening for messages
startReceiving()
connectionState = .connected
logger.info("Connected successfully")
print("✅ MAWebSocketClient.performConnect: Connection successful")
}
/// Disconnect from server
func disconnect() {
logger.info("Disconnecting")
shouldReconnect = false
reconnectTask?.cancel()
reconnectTask = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
// Cancel all pending requests
requestQueue.sync {
for (messageId, continuation) in pendingRequests {
continuation.resume(throwing: ClientError.notConnected)
}
pendingRequests.removeAll()
}
connectionState = .disconnected
eventContinuation?.finish()
}
// MARK: - Message Receiving
private func startReceiving() {
guard let task = webSocketTask else { return }
task.receive { [weak self] result in
guard let self else { return }
switch result {
case .success(let message):
self.handleMessage(message)
// Continue listening
self.startReceiving()
case .failure(let error):
logger.error("WebSocket receive error: \(error.localizedDescription)")
self.handleDisconnection()
}
}
}
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
guard case .string(let text) = message else {
logger.warning("Received non-text message")
return
}
guard let data = text.data(using: .utf8) else {
logger.error("Failed to convert message to data")
return
}
// Try to decode as response (has message_id)
if let response = try? JSONDecoder().decode(MAResponse.self, from: data),
let messageId = response.messageId {
handleResponse(messageId: messageId, response: response)
return
}
// Try to decode as event
if let event = try? JSONDecoder().decode(MAEvent.self, from: data) {
handleEvent(event)
return
}
logger.warning("Received unknown message format: \(text)")
}
private func handleResponse(messageId: String, response: MAResponse) {
requestQueue.sync {
guard let continuation = pendingRequests.removeValue(forKey: messageId) else {
logger.warning("Received response for unknown message ID: \(messageId)")
return
}
// Check for error
if let errorCode = response.errorCode {
let errorMsg = response.errorMessage ?? errorCode
continuation.resume(throwing: ClientError.serverError(errorMsg))
} else {
continuation.resume(returning: response)
}
}
}
private func handleEvent(_ event: MAEvent) {
logger.debug("Received event: \(event.event)")
eventContinuation?.yield(event)
}
private func handleDisconnection() {
connectionState = .disconnected
webSocketTask = nil
// Cancel pending requests
requestQueue.sync {
for (_, continuation) in pendingRequests {
continuation.resume(throwing: ClientError.notConnected)
}
pendingRequests.removeAll()
}
// Attempt reconnection if needed
if shouldReconnect {
scheduleReconnect(attempt: 1)
}
}
// MARK: - Reconnection
private func scheduleReconnect(attempt: Int) {
connectionState = .reconnecting(attempt: attempt)
// Exponential backoff: 3s, 10s, 30s, 30s, ...
let delay = min(
initialReconnectDelay * pow(2.0, Double(attempt - 1)),
maxReconnectDelay
)
logger.info("Scheduling reconnect attempt \(attempt) in \(delay)s")
reconnectTask = Task {
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, shouldReconnect else { return }
do {
try await performConnect()
} catch {
logger.error("Reconnect attempt \(attempt) failed: \(error.localizedDescription)")
scheduleReconnect(attempt: attempt + 1)
}
}
}
// MARK: - Sending Commands
/// Send a command and wait for response
func sendCommand(
_ command: String,
args: [String: Any]? = nil
) async throws -> MAResponse {
guard webSocketTask != nil, connectionState == .connected else {
throw ClientError.notConnected
}
let messageId = UUID().uuidString
// Convert args to AnyCodable
let encodableArgs = args?.mapValues { AnyCodable($0) }
let cmd = MACommand(
messageId: messageId,
command: command,
args: encodableArgs
)
let data = try JSONEncoder().encode(cmd)
guard let json = String(data: data, encoding: .utf8) else {
throw ClientError.decodingError(NSError(domain: "Encoding", code: -1))
}
logger.debug("Sending command: \(command) (ID: \(messageId))")
// Send message and wait for response
return try await withCheckedThrowingContinuation { continuation in
requestQueue.sync {
pendingRequests[messageId] = continuation
}
webSocketTask?.send(.string(json)) { [weak self] error in
if let error {
self?.requestQueue.sync {
_ = self?.pendingRequests.removeValue(forKey: messageId)
}
continuation.resume(throwing: error)
}
}
// Timeout after 30 seconds
Task {
try? await Task.sleep(for: .seconds(30))
self.requestQueue.sync {
if let cont = self.pendingRequests.removeValue(forKey: messageId) {
cont.resume(throwing: ClientError.timeout)
}
}
}
}
}
/// Convenience method to send command and decode result
func sendCommand<T: Decodable>(
_ command: String,
args: [String: Any]? = nil,
resultType: T.Type
) async throws -> T {
let response = try await sendCommand(command, args: args)
guard let result = response.result else {
throw ClientError.serverError("No result in response")
}
do {
return try result.decode(as: T.self)
} catch {
throw ClientError.decodingError(error)
}
}
}
@@ -0,0 +1,84 @@
//
// CachedAsyncImage.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
/// AsyncImage with URLCache support for album covers
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
let url: URL?
let content: (Image) -> Content
let placeholder: () -> Placeholder
@State private var image: UIImage?
@State private var isLoading = false
init(
url: URL?,
@ViewBuilder content: @escaping (Image) -> Content,
@ViewBuilder placeholder: @escaping () -> Placeholder
) {
self.url = url
self.content = content
self.placeholder = placeholder
}
var body: some View {
Group {
if let image {
content(Image(uiImage: image))
} else {
placeholder()
.task {
await loadImage()
}
}
}
}
private func loadImage() async {
guard let url, !isLoading else { return }
isLoading = true
defer { isLoading = false }
// Configure URLCache if needed
configureURLCache()
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let uiImage = UIImage(data: data) {
await MainActor.run {
image = uiImage
}
}
} catch {
print("Failed to load image: \(error.localizedDescription)")
}
}
private func configureURLCache() {
let cache = URLCache.shared
if cache.diskCapacity < 50_000_000 {
URLCache.shared = URLCache(
memoryCapacity: 10_000_000, // 10 MB
diskCapacity: 50_000_000 // 50 MB
)
}
}
}
// MARK: - Convenience Initializers
extension CachedAsyncImage where Content == Image, Placeholder == Color {
init(url: URL?) {
self.init(
url: url,
content: { $0.resizable() },
placeholder: { Color.gray.opacity(0.2) }
)
}
}
@@ -0,0 +1,132 @@
//
// EnhancedPlayerPickerView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
enum PlayerSelection {
case localPlayer
case remotePlayer(MAPlayer)
}
struct EnhancedPlayerPickerView: View {
@Environment(\.dismiss) private var dismiss
let players: [MAPlayer]
let supportsLocalPlayback: Bool
let onSelect: (PlayerSelection) -> Void
var body: some View {
NavigationStack {
List {
// Local iPhone Player
if supportsLocalPlayback {
Section {
Button {
onSelect(.localPlayer)
dismiss()
} label: {
HStack {
Image(systemName: "iphone")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 4) {
Text("This iPhone")
.font(.headline)
.foregroundStyle(.primary)
Text("Play directly on this device")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
.font(.caption)
}
}
} header: {
Text("Local Playback")
}
}
// Remote Players
if !players.isEmpty {
Section {
ForEach(players) { player in
Button {
onSelect(.remotePlayer(player))
dismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(player.name)
.font(.headline)
.foregroundStyle(.primary)
HStack(spacing: 6) {
Image(systemName: stateIcon(for: player.state))
.foregroundStyle(stateColor(for: player.state))
.font(.caption)
Text(player.state.rawValue.capitalized)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
.font(.caption)
}
}
.disabled(!player.available)
}
} header: {
Text("Remote Players")
}
}
}
.navigationTitle("Play on...")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
private func stateIcon(for state: PlayerState) -> String {
switch state {
case .playing: return "play.circle.fill"
case .paused: return "pause.circle.fill"
case .idle: return "stop.circle"
case .off: return "power.circle"
}
}
private func stateColor(for state: PlayerState) -> Color {
switch state {
case .playing: return .green
case .paused: return .orange
case .idle: return .gray
case .off: return .red
}
}
}
#Preview {
EnhancedPlayerPickerView(
players: [],
supportsLocalPlayback: true,
onSelect: { _ in }
)
}
@@ -0,0 +1,103 @@
//
// MiniPlayerView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct MiniPlayerView: View {
@Environment(MAService.self) private var service
let audioPlayer: MAAudioPlayer
@Binding var isExpanded: Bool
var body: some View {
HStack(spacing: 12) {
// Album Art Thumbnail
if let item = audioPlayer.currentItem,
let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 128)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
.frame(width: 48, height: 48)
.overlay {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
.font(.caption)
}
}
// Track Info
VStack(alignment: .leading, spacing: 4) {
if let item = audioPlayer.currentItem {
Text(item.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
if let mediaItem = item.mediaItem,
let artists = mediaItem.artists,
!artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
} else {
Text("No Track")
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
}
}
Spacer()
// Play/Pause Button
Button {
if audioPlayer.isPlaying {
audioPlayer.pause()
} else {
audioPlayer.play()
}
} label: {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.title3)
.foregroundStyle(.primary)
}
.padding(.trailing, 8)
}
.padding(12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 5)
.padding(.horizontal)
.padding(.bottom, 8)
.contentShape(Rectangle())
.onTapGesture {
isExpanded = true
}
}
}
#Preview {
MiniPlayerView(
audioPlayer: MAAudioPlayer(service: MAService()),
isExpanded: .constant(false)
)
.environment(MAService())
}
@@ -0,0 +1,85 @@
//
// PlayerPickerView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct PlayerPickerView: View {
@Environment(\.dismiss) private var dismiss
let players: [MAPlayer]
let onSelect: (MAPlayer) -> Void
var body: some View {
NavigationStack {
List {
ForEach(players) { player in
Button {
onSelect(player)
dismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(player.name)
.font(.headline)
.foregroundStyle(.primary)
HStack(spacing: 6) {
Image(systemName: stateIcon(for: player.state))
.foregroundStyle(stateColor(for: player.state))
.font(.caption)
Text(player.state.rawValue.capitalized)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
.font(.caption)
}
}
.disabled(!player.available)
}
}
.navigationTitle("Play on...")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
private func stateIcon(for state: PlayerState) -> String {
switch state {
case .playing: return "play.circle.fill"
case .paused: return "pause.circle.fill"
case .idle: return "stop.circle"
case .off: return "power.circle"
}
}
private func stateColor(for state: PlayerState) -> Color {
switch state {
case .playing: return .green
case .paused: return .orange
case .idle: return .gray
case .off: return .red
}
}
}
#Preview {
PlayerPickerView(
players: [],
onSelect: { _ in }
)
}
@@ -0,0 +1,322 @@
//
// AlbumDetailView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct AlbumDetailView: View {
@Environment(MAService.self) private var service
@Environment(\.audioPlayer) private var audioPlayer
let album: MAAlbum
@State private var tracks: [MAMediaItem] = []
@State private var isLoading = true
@State private var errorMessage: String?
@State private var showError = false
@State private var showPlayerPicker = false
@State private var selectedPlayer: MAPlayer?
private var players: [MAPlayer] {
Array(service.playerManager.players.values).sorted { $0.name < $1.name }
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Album Header
albumHeader
// Play Button
playButton
Divider()
// Tracklist
if isLoading {
ProgressView()
.padding()
} else if tracks.isEmpty {
Text("No tracks found")
.foregroundStyle(.secondary)
.padding()
} else {
trackList
}
}
}
.navigationTitle(album.name)
.navigationBarTitleDisplayMode(.inline)
.task {
await loadTracks()
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage {
Text(errorMessage)
}
}
.sheet(isPresented: $showPlayerPicker) {
EnhancedPlayerPickerView(
players: players,
supportsLocalPlayback: audioPlayer != nil,
onSelect: { selection in
Task {
switch selection {
case .localPlayer:
await playOnLocalPlayer()
case .remotePlayer(let player):
await playAlbum(on: player)
}
}
}
)
}
}
// MARK: - Album Header
@ViewBuilder
private var albumHeader: some View {
VStack(spacing: 16) {
// Cover Art
if let imageUrl = album.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 250, height: 250)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2))
.frame(width: 250, height: 250)
.overlay {
Image(systemName: "opticaldisc")
.font(.system(size: 60))
.foregroundStyle(.secondary)
}
}
// Album Info
VStack(spacing: 8) {
if let artists = album.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
HStack {
if let year = album.year {
Text(String(year))
.font(.subheadline)
.foregroundStyle(.tertiary)
}
if !tracks.isEmpty {
Text("")
.foregroundStyle(.tertiary)
Text("\(tracks.count) tracks")
.font(.subheadline)
.foregroundStyle(.tertiary)
}
}
}
.padding(.horizontal)
}
.padding(.top)
}
// MARK: - Play Button
@ViewBuilder
private var playButton: some View {
Button {
if players.count == 1 {
selectedPlayer = players.first
Task {
await playAlbum(on: players.first!)
}
} else {
showPlayerPicker = true
}
} label: {
Label("Play Album", systemImage: "play.fill")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal)
.disabled(tracks.isEmpty || players.isEmpty)
}
// MARK: - Track List
@ViewBuilder
private var trackList: some View {
LazyVStack(spacing: 0) {
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
TrackRow(track: track, trackNumber: index + 1)
.contentShape(Rectangle())
.onTapGesture {
if players.count == 1 {
Task {
await playTrack(track, on: players.first!)
}
} else {
showPlayerPicker = true
}
}
if index < tracks.count - 1 {
Divider()
.padding(.leading, 60)
}
}
}
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
// MARK: - Actions
private func loadTracks() async {
isLoading = true
errorMessage = nil
do {
tracks = try await service.libraryManager.getAlbumTracks(albumUri: album.uri)
isLoading = false
} catch {
errorMessage = error.localizedDescription
showError = true
isLoading = false
}
}
private func playAlbum(on player: MAPlayer) async {
do {
try await service.playerManager.playMedia(
playerId: player.playerId,
uri: album.uri
)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func playOnLocalPlayer() async {
guard let audioPlayer else {
errorMessage = "Local player not available"
showError = true
return
}
do {
// Play first track on local player
// Note: We use "local_player" as a virtual queue ID
if let firstTrack = tracks.first {
try await audioPlayer.playMediaItem(
uri: firstTrack.uri,
queueId: "local_player"
)
}
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func playTrack(_ track: MAMediaItem, on player: MAPlayer) async {
do {
try await service.playerManager.playMedia(
playerId: player.playerId,
uri: track.uri
)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Track Row
struct TrackRow: View {
let track: MAMediaItem
let trackNumber: Int
var body: some View {
HStack(spacing: 12) {
// Track Number
Text("\(trackNumber)")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(width: 30, alignment: .trailing)
// Track Info
VStack(alignment: .leading, spacing: 4) {
Text(track.name)
.font(.body)
.lineLimit(1)
if let artists = track.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
// Duration
if let duration = track.duration {
Text(formatDuration(duration))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
.padding(.horizontal)
}
private func formatDuration(_ seconds: Int) -> String {
let minutes = seconds / 60
let remainingSeconds = seconds % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
#Preview {
NavigationStack {
AlbumDetailView(
album: MAAlbum(
uri: "library://album/1",
name: "Test Album",
artists: [
MAArtist(uri: "library://artist/1", name: "Test Artist", imageUrl: nil, sortName: nil, musicbrainzId: nil)
],
imageUrl: nil,
year: 2024
)
)
.environment(MAService())
}
}
@@ -0,0 +1,160 @@
//
// AlbumsView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct AlbumsView: View {
@Environment(MAService.self) private var service
@State private var errorMessage: String?
@State private var showError = false
private var albums: [MAAlbum] {
service.libraryManager.albums
}
private var isLoading: Bool {
service.libraryManager.isLoadingAlbums
}
private let columns = [
GridItem(.adaptive(minimum: 160), spacing: 16)
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(albums) { album in
NavigationLink(value: album) {
AlbumGridItem(album: album)
}
.buttonStyle(.plain)
.task {
await loadMoreIfNeeded(currentItem: album)
}
}
if isLoading {
ProgressView()
.gridCellColumns(columns.count)
.padding()
}
}
.padding()
}
.navigationDestination(for: MAAlbum.self) { album in
AlbumDetailView(album: album)
}
.refreshable {
await loadAlbums(refresh: true)
}
.task {
if albums.isEmpty {
await loadAlbums(refresh: false)
}
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage {
Text(errorMessage)
}
}
.overlay {
if albums.isEmpty && !isLoading {
ContentUnavailableView(
"No Albums",
systemImage: "square.stack",
description: Text("Your library doesn't contain any albums yet")
)
}
}
}
private func loadAlbums(refresh: Bool) async {
do {
try await service.libraryManager.loadAlbums(refresh: refresh)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func loadMoreIfNeeded(currentItem: MAAlbum) async {
do {
try await service.libraryManager.loadMoreAlbumsIfNeeded(currentItem: currentItem)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Album Grid Item
struct AlbumGridItem: View {
@Environment(MAService.self) private var service
let album: MAAlbum
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Album Cover
if let imageUrl = album.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 256)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 160, height: 160)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
.frame(width: 160, height: 160)
.overlay {
Image(systemName: "opticaldisc")
.font(.system(size: 40))
.foregroundStyle(.secondary)
}
}
// Album Info
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(2)
.foregroundStyle(.primary)
if let artists = album.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let year = album.year {
Text(String(year))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.frame(width: 160, alignment: .leading)
}
}
}
#Preview {
NavigationStack {
AlbumsView()
.environment(MAService())
}
}
@@ -0,0 +1,71 @@
//
// ArtistDetailView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct ArtistDetailView: View {
@Environment(MAService.self) private var service
let artist: MAArtist
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Artist Header
VStack(spacing: 16) {
// Artist Image
if let imageUrl = artist.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 250, height: 250)
.clipShape(Circle())
.shadow(radius: 10)
} else {
Circle()
.fill(Color.gray.opacity(0.2))
.frame(width: 250, height: 250)
.overlay {
Image(systemName: "music.mic")
.font(.system(size: 60))
.foregroundStyle(.secondary)
}
}
}
.padding(.top)
// TODO: Load artist albums, top tracks, etc.
Text("Artist details coming soon")
.foregroundStyle(.secondary)
.padding()
}
}
.navigationTitle(artist.name)
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
NavigationStack {
ArtistDetailView(
artist: MAArtist(
uri: "library://artist/1",
name: "Test Artist",
imageUrl: nil,
sortName: nil,
musicbrainzId: nil
)
)
.environment(MAService())
}
}
@@ -0,0 +1,145 @@
//
// ArtistsView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct ArtistsView: View {
@Environment(MAService.self) private var service
@State private var errorMessage: String?
@State private var showError = false
private var artists: [MAArtist] {
service.libraryManager.artists
}
private var isLoading: Bool {
service.libraryManager.isLoadingArtists
}
private let columns = [
GridItem(.adaptive(minimum: 160), spacing: 16)
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(artists) { artist in
NavigationLink(value: artist) {
ArtistGridItem(artist: artist)
}
.buttonStyle(.plain)
.task {
await loadMoreIfNeeded(currentItem: artist)
}
}
if isLoading {
ProgressView()
.gridCellColumns(columns.count)
.padding()
}
}
.padding()
}
.navigationDestination(for: MAArtist.self) { artist in
ArtistDetailView(artist: artist)
}
.refreshable {
await loadArtists(refresh: true)
}
.task {
if artists.isEmpty {
await loadArtists(refresh: false)
}
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage {
Text(errorMessage)
}
}
.overlay {
if artists.isEmpty && !isLoading {
ContentUnavailableView(
"No Artists",
systemImage: "music.mic",
description: Text("Your library doesn't contain any artists yet")
)
}
}
}
private func loadArtists(refresh: Bool) async {
do {
try await service.libraryManager.loadArtists(refresh: refresh)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func loadMoreIfNeeded(currentItem: MAArtist) async {
do {
try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Artist Grid Item
struct ArtistGridItem: View {
@Environment(MAService.self) private var service
let artist: MAArtist
var body: some View {
VStack(spacing: 8) {
// Artist Image
if let imageUrl = artist.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 256)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 160, height: 160)
.clipShape(Circle())
} else {
Circle()
.fill(Color.gray.opacity(0.2))
.frame(width: 160, height: 160)
.overlay {
Image(systemName: "music.mic")
.font(.system(size: 40))
.foregroundStyle(.secondary)
}
}
// Artist Name
Text(artist.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(2)
.multilineTextAlignment(.center)
.foregroundStyle(.primary)
}
}
}
#Preview {
NavigationStack {
ArtistsView()
.environment(MAService())
}
}
@@ -0,0 +1,46 @@
//
// LibraryView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct LibraryView: View {
@Environment(MAService.self) private var service
var body: some View {
NavigationStack {
TabView {
Tab("Artists", systemImage: "music.mic") {
ArtistsView()
}
Tab("Albums", systemImage: "square.stack") {
AlbumsView()
}
Tab("Playlists", systemImage: "music.note.list") {
PlaylistsView()
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .primaryAction) {
NavigationLink {
SearchView()
} label: {
Label("Search", systemImage: "magnifyingglass")
}
}
}
}
}
}
#Preview {
LibraryView()
.environment(MAService())
}
@@ -0,0 +1,86 @@
//
// PlaylistDetailView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct PlaylistDetailView: View {
@Environment(MAService.self) private var service
let playlist: MAPlaylist
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Playlist Header
VStack(spacing: 16) {
// Playlist Cover
if let imageUrl = playlist.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 250, height: 250)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2))
.frame(width: 250, height: 250)
.overlay {
Image(systemName: "music.note.list")
.font(.system(size: 60))
.foregroundStyle(.secondary)
}
}
// Playlist Info
VStack(spacing: 8) {
if let owner = playlist.owner {
Text("By \(owner)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
if playlist.isEditable {
Label("Editable", systemImage: "pencil")
.font(.caption)
.foregroundStyle(.blue)
}
}
}
.padding(.top)
// TODO: Load playlist tracks
Text("Playlist details coming soon")
.foregroundStyle(.secondary)
.padding()
}
}
.navigationTitle(playlist.name)
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
NavigationStack {
PlaylistDetailView(
playlist: MAPlaylist(
uri: "library://playlist/1",
name: "Test Playlist",
owner: "Test User",
imageUrl: nil,
isEditable: true
)
)
.environment(MAService())
}
}
@@ -0,0 +1,138 @@
//
// PlaylistsView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct PlaylistsView: View {
@Environment(MAService.self) private var service
@State private var errorMessage: String?
@State private var showError = false
private var playlists: [MAPlaylist] {
service.libraryManager.playlists
}
private var isLoading: Bool {
service.libraryManager.isLoadingPlaylists
}
var body: some View {
Group {
if isLoading && playlists.isEmpty {
ProgressView()
} else if playlists.isEmpty {
ContentUnavailableView(
"No Playlists",
systemImage: "music.note.list",
description: Text("Your library doesn't contain any playlists yet")
)
} else {
List {
ForEach(playlists) { playlist in
NavigationLink(value: playlist) {
PlaylistRow(playlist: playlist)
}
}
}
.listStyle(.plain)
}
}
.navigationDestination(for: MAPlaylist.self) { playlist in
PlaylistDetailView(playlist: playlist)
}
.refreshable {
await loadPlaylists(refresh: true)
}
.task {
if playlists.isEmpty {
await loadPlaylists(refresh: false)
}
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage {
Text(errorMessage)
}
}
}
private func loadPlaylists(refresh: Bool) async {
do {
try await service.libraryManager.loadPlaylists(refresh: refresh)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Playlist Row
struct PlaylistRow: View {
@Environment(MAService.self) private var service
let playlist: MAPlaylist
var body: some View {
HStack(spacing: 12) {
// Playlist Cover
if let imageUrl = playlist.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 128)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 64, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
.frame(width: 64, height: 64)
.overlay {
Image(systemName: "music.note.list")
.font(.title2)
.foregroundStyle(.secondary)
}
}
// Playlist Info
VStack(alignment: .leading, spacing: 4) {
Text(playlist.name)
.font(.headline)
.lineLimit(1)
if let owner = playlist.owner {
Text("By \(owner)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if playlist.isEditable {
Label("Editable", systemImage: "pencil")
.font(.caption2)
.foregroundStyle(.blue)
}
}
Spacer()
}
.padding(.vertical, 4)
}
}
#Preview {
NavigationStack {
PlaylistsView()
.environment(MAService())
}
}
@@ -0,0 +1,219 @@
//
// SearchView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct SearchView: View {
@Environment(MAService.self) private var service
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var searchResults: [MAMediaItem] = []
@State private var isSearching = false
@State private var errorMessage: String?
@State private var showError = false
// Debounce timer
@State private var searchTask: Task<Void, Never>?
var body: some View {
NavigationStack {
Group {
if searchResults.isEmpty && !isSearching {
if searchText.isEmpty {
ContentUnavailableView(
"Search Library",
systemImage: "magnifyingglass",
description: Text("Find artists, albums, tracks, and playlists")
)
} else {
ContentUnavailableView(
"No Results",
systemImage: "magnifyingglass",
description: Text("No results found for '\(searchText)'")
)
}
} else if isSearching {
ProgressView()
} else {
searchResultsList
}
}
.navigationTitle("Search")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $searchText, prompt: "Artists, albums, tracks...")
.onChange(of: searchText) { _, newValue in
performSearch(query: newValue)
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage {
Text(errorMessage)
}
}
}
}
// MARK: - Search Results List
@ViewBuilder
private var searchResultsList: some View {
List {
ForEach(searchResults) { item in
SearchResultRow(item: item)
.contentShape(Rectangle())
.onTapGesture {
// TODO: Navigate to detail view based on media type
}
}
}
.listStyle(.plain)
}
// MARK: - Search
private func performSearch(query: String) {
// Cancel previous search
searchTask?.cancel()
guard !query.isEmpty else {
searchResults = []
return
}
// Debounce search - wait 500ms after user stops typing
searchTask = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
await executeSearch(query: query)
}
}
private func executeSearch(query: String) async {
isSearching = true
errorMessage = nil
do {
let results = try await service.libraryManager.search(query: query)
await MainActor.run {
searchResults = results
isSearching = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
showError = true
isSearching = false
}
}
}
}
// MARK: - Search Result Row
struct SearchResultRow: View {
@Environment(MAService.self) private var service
let item: MAMediaItem
var body: some View {
HStack(spacing: 12) {
// Thumbnail
if let imageUrl = item.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 128)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 60, height: 60)
.clipShape(thumbnailShape)
} else {
thumbnailShape
.fill(Color.gray.opacity(0.2))
.frame(width: 60, height: 60)
.overlay {
Image(systemName: mediaTypeIcon)
.foregroundStyle(.secondary)
}
}
// Item Info
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(.body)
.lineLimit(1)
if let artists = item.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
} else if let album = item.album {
Text(album.name)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Label(item.mediaType.rawValue.capitalized, systemImage: mediaTypeIcon)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
}
.padding(.vertical, 4)
}
private var thumbnailShape: some Shape {
switch item.mediaType {
case .artist:
return AnyShape(Circle())
default:
return AnyShape(RoundedRectangle(cornerRadius: 8))
}
}
private var mediaTypeIcon: String {
switch item.mediaType {
case .track: return "music.note"
case .album: return "opticaldisc"
case .artist: return "music.mic"
case .playlist: return "music.note.list"
case .radio: return "antenna.radiowaves.left.and.right"
}
}
}
// MARK: - AnyShape Helper
struct AnyShape: Shape {
private let _path: (CGRect) -> Path
init<S: Shape>(_ shape: S) {
_path = { rect in
shape.path(in: rect)
}
}
func path(in rect: CGRect) -> Path {
_path(rect)
}
}
#Preview {
SearchView()
.environment(MAService())
}
@@ -0,0 +1,233 @@
//
// LocalPlayerView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct LocalPlayerView: View {
@Environment(MAService.self) private var service
@Environment(\.audioPlayer) private var audioPlayer
var body: some View {
NavigationStack {
VStack(spacing: 24) {
if let player = audioPlayer {
// Now Playing Section
nowPlayingSection(player: player)
// Progress Bar
progressBar(player: player)
// Transport Controls
transportControls(player: player)
// Volume Control
volumeControl(player: player)
} else {
ContentUnavailableView(
"No Active Playback",
systemImage: "play.circle",
description: Text("Play something from your library to see controls here")
)
}
Spacer()
}
.padding()
.navigationTitle("Now Playing")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Now Playing Section
@ViewBuilder
private func nowPlayingSection(player: MAAudioPlayer) -> some View {
VStack(spacing: 16) {
// Album Art
if let item = player.currentItem,
let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
.overlay {
ProgressView()
}
}
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2))
.frame(width: 300, height: 300)
.overlay {
Image(systemName: "music.note")
.font(.system(size: 60))
.foregroundStyle(.secondary)
}
.shadow(radius: 10)
}
// Track Info
VStack(spacing: 8) {
if let item = player.currentItem {
Text(item.name)
.font(.title2)
.fontWeight(.semibold)
.multilineTextAlignment(.center)
if let mediaItem = item.mediaItem {
if let artists = mediaItem.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
if let album = mediaItem.album {
Text(album.name)
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
}
} else {
Text("No Track Playing")
.font(.title2)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
}
.padding(.top)
}
// MARK: - Progress Bar
@ViewBuilder
private func progressBar(player: MAAudioPlayer) -> some View {
VStack(spacing: 8) {
// Progress slider
Slider(
value: Binding(
get: { player.currentTime },
set: { player.seek(to: $0) }
),
in: 0...max(1, player.duration)
)
// Time labels
HStack {
Text(formatTime(player.currentTime))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(formatTime(player.duration))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
}
// MARK: - Transport Controls
@ViewBuilder
private func transportControls(player: MAAudioPlayer) -> some View {
HStack(spacing: 40) {
// Previous
Button {
Task {
await player.previousTrack()
}
} label: {
Image(systemName: "backward.fill")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
// Play/Pause
Button {
if player.isPlaying {
player.pause()
} else {
player.play()
}
} label: {
Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.primary)
}
// Next
Button {
Task {
await player.nextTrack()
}
} label: {
Image(systemName: "forward.fill")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
}
.padding()
}
// MARK: - Volume Control
@ViewBuilder
private func volumeControl(player: MAAudioPlayer) -> some View {
VStack(spacing: 12) {
HStack {
Image(systemName: "speaker.fill")
.foregroundStyle(.secondary)
// System volume - read-only on iOS
Slider(
value: Binding(
get: { Double(player.volume) },
set: { _ in }
),
in: 0...1
)
.disabled(true)
Image(systemName: "speaker.wave.3.fill")
.foregroundStyle(.secondary)
}
Text("Use device volume buttons")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
}
// MARK: - Helpers
private func formatTime(_ seconds: TimeInterval) -> String {
guard seconds.isFinite else { return "0:00" }
let minutes = Int(seconds) / 60
let remainingSeconds = Int(seconds) % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
#Preview {
LocalPlayerView()
.environment(MAService())
}
+163
View File
@@ -0,0 +1,163 @@
//
// LoginView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct LoginView: View {
@Environment(MAService.self) private var service
@State private var serverURL = "https://"
@State private var token = ""
@State private var showToken = false
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showError = false
var body: some View {
NavigationStack {
Form {
// Server URL Section
Section {
TextField("Server URL", text: $serverURL)
.textContentType(.URL)
.keyboardType(.URL)
.autocapitalization(.none)
.autocorrectionDisabled()
} header: {
Text("Server")
} footer: {
Text("Enter your Music Assistant server URL (e.g., https://musicassistant-app.hanold.online)")
}
// Token Section
Section {
HStack {
Group {
if showToken {
TextField("Long-Lived Access Token", text: $token)
.textContentType(.password)
.autocapitalization(.none)
.autocorrectionDisabled()
} else {
SecureField("Long-Lived Access Token", text: $token)
.textContentType(.password)
}
}
Button {
showToken.toggle()
} label: {
Image(systemName: showToken ? "eye.slash.fill" : "eye.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
} header: {
Text("Authentication")
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("How to get a token:")
Text("1. Open Music Assistant in a browser")
Text("2. Go to Settings → Users")
Text("3. Create a new long-lived access token")
Text("4. Copy and paste the token here")
}
.font(.caption)
.foregroundStyle(.secondary)
}
// Connect Button
Section {
Button {
Task {
await login()
}
} label: {
if isLoading {
HStack {
Spacer()
ProgressView()
Text("Connecting...")
.padding(.leading, 8)
Spacer()
}
} else {
HStack {
Spacer()
Text("Connect")
.fontWeight(.semibold)
Spacer()
}
}
}
.disabled(isLoading || !isFormValid)
}
}
.navigationTitle("Music Assistant")
.alert("Connection Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage {
Text(errorMessage)
}
}
}
}
// MARK: - Computed Properties
private var isFormValid: Bool {
!serverURL.isEmpty &&
serverURL.starts(with: "http") &&
!token.isEmpty
}
// MARK: - Actions
private func login() async {
guard let url = URL(string: serverURL) else {
showError(message: "Invalid server URL")
return
}
isLoading = true
errorMessage = nil
print("🔵 LoginView: Starting login with long-lived token")
print("🔵 LoginView: Server URL = \(url.absoluteString)")
print("🔵 LoginView: Token length = \(token.count)")
do {
// Save token to keychain
try service.authManager.saveToken(serverURL: url, token: token)
print("✅ LoginView: Token saved to keychain")
// Connect WebSocket with token
print("🔵 LoginView: Connecting WebSocket")
try await service.connect(serverURL: url, token: token)
print("✅ LoginView: Connected successfully")
isLoading = false
} catch {
print("❌ LoginView: Login failed - \(error)")
isLoading = false
showError(message: error.localizedDescription)
}
}
private func showError(message: String) {
errorMessage = message
showError = true
}
}
#Preview {
LoginView()
.environment(MAService())
}
@@ -0,0 +1,246 @@
//
// MainTabView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct MainTabView: View {
@Environment(MAService.self) private var service
var body: some View {
TabView {
Tab("Players", systemImage: "speaker.wave.2.fill") {
PlayerListView()
}
Tab("Library", systemImage: "music.note.list") {
LibraryView()
}
Tab("Settings", systemImage: "gear") {
SettingsView()
}
}
.task {
// Start listening to player events when main view appears
service.playerManager.startListening()
}
.onDisappear {
// Stop listening when view disappears
service.playerManager.stopListening()
}
}
}
// MARK: - Placeholder Views (to be implemented in Phase 2+)
struct PlayerListView: View {
@Environment(MAService.self) private var service
@State private var isLoading = false
@State private var errorMessage: String?
private var players: [MAPlayer] {
Array(service.playerManager.players.values).sorted { $0.name < $1.name }
}
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView()
} else if let errorMessage {
ContentUnavailableView(
"Error Loading Players",
systemImage: "exclamationmark.triangle",
description: Text(errorMessage)
)
} else if players.isEmpty {
ContentUnavailableView(
"No Players Found",
systemImage: "speaker.slash",
description: Text("Make sure your Music Assistant server has configured players")
)
} else {
List(players) { player in
NavigationLink(value: player.playerId) {
PlayerRow(player: player)
}
}
.navigationDestination(for: String.self) { playerId in
PlayerView(playerId: playerId)
}
}
}
.navigationTitle("Players")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task {
await loadPlayers()
}
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
}
}
.task {
await loadPlayers()
}
}
}
private func loadPlayers() async {
print("🔵 PlayerListView: Starting to load players...")
isLoading = true
errorMessage = nil
do {
print("🔵 PlayerListView: Calling playerManager.loadPlayers()")
try await service.playerManager.loadPlayers()
print("✅ PlayerListView: Successfully loaded \(players.count) players")
} catch {
print("❌ PlayerListView: Failed to load players: \(error)")
errorMessage = error.localizedDescription
}
isLoading = false
}
}
struct PlayerRow: View {
@Environment(MAService.self) private var service
let player: MAPlayer
var body: some View {
HStack(spacing: 12) {
// Album Art Thumbnail
if let item = player.currentItem,
let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 64)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
.frame(width: 48, height: 48)
.overlay {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
.font(.caption)
}
}
// Player Info
VStack(alignment: .leading, spacing: 4) {
Text(player.name)
.font(.headline)
HStack(spacing: 6) {
Image(systemName: stateIcon)
.foregroundStyle(stateColor)
.font(.caption)
Text(player.state.rawValue.capitalized)
.font(.caption)
.foregroundStyle(.secondary)
if let item = player.currentItem {
Text("\(item.name)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
Spacer()
// Volume Indicator
if player.available {
VStack(spacing: 2) {
Image(systemName: "speaker.wave.2.fill")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(player.volume)%")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch player.state {
case .playing: return "play.circle.fill"
case .paused: return "pause.circle.fill"
case .idle: return "stop.circle"
case .off: return "power.circle"
}
}
private var stateColor: Color {
switch player.state {
case .playing: return .green
case .paused: return .orange
case .idle: return .gray
case .off: return .red
}
}
}
// Removed - Now using dedicated PlayerView.swift file
// Removed - Now using dedicated LibraryView.swift file
struct SettingsView: View {
@Environment(MAService.self) private var service
var body: some View {
NavigationStack {
Form {
Section {
if let serverURL = service.authManager.serverURL {
LabeledContent("Server", value: serverURL.absoluteString)
}
LabeledContent("Status") {
HStack {
Circle()
.fill(service.isConnected ? .green : .red)
.frame(width: 8, height: 8)
Text(service.isConnected ? "Connected" : "Disconnected")
}
}
}
Section {
Button(role: .destructive) {
service.disconnect()
service.authManager.logout()
} label: {
Label("Disconnect", systemImage: "arrow.right.square")
}
}
}
.navigationTitle("Settings")
}
}
}
#Preview {
MainTabView()
.environment(MAService())
}
@@ -0,0 +1,379 @@
//
// PlayerView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct PlayerView: View {
@Environment(MAService.self) private var service
let playerId: String
@State private var player: MAPlayer?
@State private var queueItems: [MAQueueItem] = []
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
ScrollView {
VStack(spacing: 24) {
if let player {
// Now Playing Section
nowPlayingSection(player: player)
// Transport Controls
transportControls(player: player)
// Volume Control
volumeControl(player: player)
Divider()
.padding(.vertical, 8)
// Queue Section
queueSection
} else if isLoading {
ProgressView()
.padding()
} else if let errorMessage {
ContentUnavailableView(
"Error",
systemImage: "exclamationmark.triangle",
description: Text(errorMessage)
)
}
}
.padding()
}
.navigationTitle(player?.name ?? "Player")
.navigationBarTitleDisplayMode(.inline)
.task {
await loadPlayerData()
observePlayerUpdates()
}
.refreshable {
await loadPlayerData()
}
}
// MARK: - Now Playing Section
@ViewBuilder
private func nowPlayingSection(player: MAPlayer) -> some View {
VStack(spacing: 16) {
// Album Art
if let currentItem = player.currentItem,
let mediaItem = currentItem.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
.overlay {
Image(systemName: "music.note")
.font(.system(size: 60))
.foregroundStyle(.secondary)
}
}
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
} else {
Rectangle()
.fill(Color.gray.opacity(0.2))
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay {
Image(systemName: "music.note")
.font(.system(size: 60))
.foregroundStyle(.secondary)
}
}
// Track Info
VStack(spacing: 8) {
if let currentItem = player.currentItem {
Text(currentItem.name)
.font(.title2)
.fontWeight(.semibold)
.multilineTextAlignment(.center)
if let mediaItem = currentItem.mediaItem {
if let artists = mediaItem.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
if let album = mediaItem.album {
Text(album.name)
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
}
} else {
Text("No Track Playing")
.font(.title3)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
}
}
// MARK: - Transport Controls
@ViewBuilder
private func transportControls(player: MAPlayer) -> some View {
HStack(spacing: 40) {
// Previous
Button {
Task {
try? await service.playerManager.previousTrack(playerId: playerId)
}
} label: {
Image(systemName: "backward.fill")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
.disabled(!player.available)
// Play/Pause
Button {
Task {
if player.state == .playing {
try? await service.playerManager.pause(playerId: playerId)
} else {
try? await service.playerManager.play(playerId: playerId)
}
}
} label: {
Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.primary)
}
.disabled(!player.available)
// Next
Button {
Task {
try? await service.playerManager.nextTrack(playerId: playerId)
}
} label: {
Image(systemName: "forward.fill")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
.disabled(!player.available)
}
.padding()
}
// MARK: - Volume Control
@ViewBuilder
private func volumeControl(player: MAPlayer) -> some View {
VStack(spacing: 12) {
HStack {
Image(systemName: "speaker.fill")
.foregroundStyle(.secondary)
Slider(
value: Binding(
get: { Double(player.volume) },
set: { newValue in
Task {
try? await service.playerManager.setVolume(
playerId: playerId,
level: Int(newValue)
)
}
}
),
in: 0...100,
step: 1
)
Image(systemName: "speaker.wave.3.fill")
.foregroundStyle(.secondary)
}
Text("\(player.volume)%")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.disabled(!player.available)
}
// MARK: - Queue Section
@ViewBuilder
private var queueSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Queue")
.font(.headline)
.padding(.horizontal)
if queueItems.isEmpty {
Text("Queue is empty")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
} else {
LazyVStack(spacing: 0) {
ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in
QueueItemRow(item: item, index: index)
.contentShape(Rectangle())
.onTapGesture {
Task {
try? await service.playerManager.playIndex(
playerId: playerId,
index: index
)
}
}
if index < queueItems.count - 1 {
Divider()
.padding(.leading, 60)
}
}
}
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
}
}
// MARK: - Data Loading
private func loadPlayerData() async {
isLoading = true
errorMessage = nil
do {
// Load player info
let players = try await service.getPlayers()
player = players.first { $0.playerId == playerId }
// Load queue
let items = try await service.getQueue(playerId: playerId)
queueItems = items
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
private func observePlayerUpdates() {
// Observe player updates from PlayerManager
Task {
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(100))
// Update from PlayerManager cache
if let updatedPlayer = service.playerManager.players[playerId] {
await MainActor.run {
player = updatedPlayer
}
}
if let updatedQueue = service.playerManager.queues[playerId] {
await MainActor.run {
queueItems = updatedQueue
}
}
}
}
}
}
// MARK: - Queue Item Row
struct QueueItemRow: View {
@Environment(MAService.self) private var service
let item: MAQueueItem
let index: Int
var body: some View {
HStack(spacing: 12) {
// Thumbnail
if let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 64)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
.frame(width: 48, height: 48)
.overlay {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
}
}
// Track Info
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(.body)
.lineLimit(1)
if let mediaItem = item.mediaItem,
let artists = mediaItem.artists,
!artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
// Duration
if let duration = item.duration {
Text(formatDuration(duration))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
.padding(.horizontal)
}
private func formatDuration(_ seconds: Int) -> String {
let minutes = seconds / 60
let remainingSeconds = seconds % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
#Preview {
NavigationStack {
PlayerView(playerId: "test_player")
.environment(MAService())
}
}
@@ -0,0 +1,56 @@
//
// RootView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct RootView: View {
@Environment(MAService.self) private var service
@State private var isInitializing = true
var body: some View {
Group {
if isInitializing {
// Loading screen while checking for saved credentials
VStack(spacing: 20) {
ProgressView()
Text("Connecting...")
.foregroundStyle(.secondary)
}
} else if service.isConnected {
// Main app view when connected
MainTabView()
} else {
// Login view when not connected
LoginView()
}
}
.task {
await initializeConnection()
}
}
// MARK: - Initialization
private func initializeConnection() async {
// Try to connect with saved credentials
if service.authManager.isAuthenticated {
do {
try await service.connectWithSavedCredentials()
} catch {
print("Auto-connect failed: \(error.localizedDescription)")
}
}
isInitializing = false
}
}
#Preview {
RootView()
.environment(MAService())
}