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