Initial Commit
This commit is contained in:
@@ -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 */;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user