From e9b6412d71f9cd7948cea0eef948528accf396c8 Mon Sep 17 00:00:00 2001 From: Sven Date: Fri, 27 Mar 2026 09:21:41 +0100 Subject: [PATCH] Initial Commit --- .../project.pbxproj | 337 +++++++++++++ .../contents.xcworkspacedata | 7 + .../xcschemes/xcschememanagement.plist | 14 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + Mobile Music Assistant/ContentView.swift | 24 + .../DocsAudioStreamingIntegration.md | 145 ++++++ .../DocsDomain-Troubleshooting.md | 218 +++++++++ .../DocsHTTPS-Troubleshooting.md | 138 ++++++ Mobile Music Assistant/DocsLongLivedTokens.md | 233 +++++++++ .../DocsReverseProxy-Setup.md | 381 +++++++++++++++ .../DocsTroubleshooting-Loading.md | 308 ++++++++++++ .../HelpersAudioPlayerEnvironment.swift | 20 + Mobile Music Assistant/Info.plist.md | 75 +++ .../Mobile_Music_AssistantApp.swift | 20 + Mobile Music Assistant/ModelsMAModels.swift | 299 ++++++++++++ .../ServicesMAAudioPlayer.swift | 444 ++++++++++++++++++ .../ServicesMAAuthManager.swift | 285 +++++++++++ .../ServicesMALibraryManager.swift | 217 +++++++++ .../ServicesMAPlayerManager.swift | 205 ++++++++ .../ServicesMAService.swift | 331 +++++++++++++ .../ServicesMAWebSocketClient.swift | 380 +++++++++++++++ .../ViewsComponentsCachedAsyncImage.swift | 84 ++++ ...wsComponentsEnhancedPlayerPickerView.swift | 132 ++++++ .../ViewsComponentsMiniPlayerView.swift | 103 ++++ .../ViewsComponentsPlayerPickerView.swift | 85 ++++ .../ViewsLibraryAlbumDetailView.swift | 322 +++++++++++++ .../ViewsLibraryAlbumsView.swift | 160 +++++++ .../ViewsLibraryArtistDetailView.swift | 71 +++ .../ViewsLibraryArtistsView.swift | 145 ++++++ .../ViewsLibraryLibraryView.swift | 46 ++ .../ViewsLibraryPlaylistDetailView.swift | 86 ++++ .../ViewsLibraryPlaylistsView.swift | 138 ++++++ .../ViewsLibrarySearchView.swift | 219 +++++++++ .../ViewsLocalPlayerView.swift | 233 +++++++++ Mobile Music Assistant/ViewsLoginView.swift | 163 +++++++ Mobile Music Assistant/ViewsMainTabView.swift | 246 ++++++++++ Mobile Music Assistant/ViewsPlayerView.swift | 379 +++++++++++++++ Mobile Music Assistant/ViewsRootView.swift | 56 +++ 40 files changed, 6801 insertions(+) create mode 100644 Mobile Music Assistant.xcodeproj/project.pbxproj create mode 100644 Mobile Music Assistant.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 Mobile Music Assistant/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Mobile Music Assistant/Assets.xcassets/Contents.json create mode 100644 Mobile Music Assistant/ContentView.swift create mode 100644 Mobile Music Assistant/DocsAudioStreamingIntegration.md create mode 100644 Mobile Music Assistant/DocsDomain-Troubleshooting.md create mode 100644 Mobile Music Assistant/DocsHTTPS-Troubleshooting.md create mode 100644 Mobile Music Assistant/DocsLongLivedTokens.md create mode 100644 Mobile Music Assistant/DocsReverseProxy-Setup.md create mode 100644 Mobile Music Assistant/DocsTroubleshooting-Loading.md create mode 100644 Mobile Music Assistant/HelpersAudioPlayerEnvironment.swift create mode 100644 Mobile Music Assistant/Info.plist.md create mode 100644 Mobile Music Assistant/Mobile_Music_AssistantApp.swift create mode 100644 Mobile Music Assistant/ModelsMAModels.swift create mode 100644 Mobile Music Assistant/ServicesMAAudioPlayer.swift create mode 100644 Mobile Music Assistant/ServicesMAAuthManager.swift create mode 100644 Mobile Music Assistant/ServicesMALibraryManager.swift create mode 100644 Mobile Music Assistant/ServicesMAPlayerManager.swift create mode 100644 Mobile Music Assistant/ServicesMAService.swift create mode 100644 Mobile Music Assistant/ServicesMAWebSocketClient.swift create mode 100644 Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift create mode 100644 Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift create mode 100644 Mobile Music Assistant/ViewsComponentsMiniPlayerView.swift create mode 100644 Mobile Music Assistant/ViewsComponentsPlayerPickerView.swift create mode 100644 Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift create mode 100644 Mobile Music Assistant/ViewsLibraryAlbumsView.swift create mode 100644 Mobile Music Assistant/ViewsLibraryArtistDetailView.swift create mode 100644 Mobile Music Assistant/ViewsLibraryArtistsView.swift create mode 100644 Mobile Music Assistant/ViewsLibraryLibraryView.swift create mode 100644 Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift create mode 100644 Mobile Music Assistant/ViewsLibraryPlaylistsView.swift create mode 100644 Mobile Music Assistant/ViewsLibrarySearchView.swift create mode 100644 Mobile Music Assistant/ViewsLocalPlayerView.swift create mode 100644 Mobile Music Assistant/ViewsLoginView.swift create mode 100644 Mobile Music Assistant/ViewsMainTabView.swift create mode 100644 Mobile Music Assistant/ViewsPlayerView.swift create mode 100644 Mobile Music Assistant/ViewsRootView.swift diff --git a/Mobile Music Assistant.xcodeproj/project.pbxproj b/Mobile Music Assistant.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7d42fa4 --- /dev/null +++ b/Mobile Music Assistant.xcodeproj/project.pbxproj @@ -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 = ""; + }; +/* 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 = ""; + }; + 26ED92622F759EEA0025419D /* Products */ = { + isa = PBXGroup; + children = ( + 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/Mobile Music Assistant.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Mobile Music Assistant.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Mobile Music Assistant.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist b/Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..fadb802 --- /dev/null +++ b/Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Mobile Music Assistant.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Mobile Music Assistant/Assets.xcassets/AccentColor.colorset/Contents.json b/Mobile Music Assistant/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Mobile Music Assistant/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/Contents.json b/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/Mobile Music Assistant/Assets.xcassets/Contents.json b/Mobile Music Assistant/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Mobile Music Assistant/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mobile Music Assistant/ContentView.swift b/Mobile Music Assistant/ContentView.swift new file mode 100644 index 0000000..4b420c4 --- /dev/null +++ b/Mobile Music Assistant/ContentView.swift @@ -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() +} diff --git a/Mobile Music Assistant/DocsAudioStreamingIntegration.md b/Mobile Music Assistant/DocsAudioStreamingIntegration.md new file mode 100644 index 0000000..6f1bd5a --- /dev/null +++ b/Mobile Music Assistant/DocsAudioStreamingIntegration.md @@ -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// +``` + +## 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) diff --git a/Mobile Music Assistant/DocsDomain-Troubleshooting.md b/Mobile Music Assistant/DocsDomain-Troubleshooting.md new file mode 100644 index 0000000..a659bc1 --- /dev/null +++ b/Mobile Music Assistant/DocsDomain-Troubleshooting.md @@ -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 diff --git a/Mobile Music Assistant/DocsHTTPS-Troubleshooting.md b/Mobile Music Assistant/DocsHTTPS-Troubleshooting.md new file mode 100644 index 0000000..fc3621d --- /dev/null +++ b/Mobile Music Assistant/DocsHTTPS-Troubleshooting.md @@ -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 +NSAppTransportSecurity + + NSAllowsArbitraryLoads + + +``` + +**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 +NSAppTransportSecurity + + NSExceptionDomains + + 192.168.1.100 + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + +``` + +## 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 + + + + + + + + UIBackgroundModes + + audio + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + +``` + +## 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 diff --git a/Mobile Music Assistant/DocsLongLivedTokens.md b/Mobile Music Assistant/DocsLongLivedTokens.md new file mode 100644 index 0000000..dc4be7f --- /dev/null +++ b/Mobile Music Assistant/DocsLongLivedTokens.md @@ -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 diff --git a/Mobile Music Assistant/DocsReverseProxy-Setup.md b/Mobile Music Assistant/DocsReverseProxy-Setup.md new file mode 100644 index 0000000..fcdb53d --- /dev/null +++ b/Mobile Music Assistant/DocsReverseProxy-Setup.md @@ -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 + + 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/ + +``` + +## 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 diff --git a/Mobile Music Assistant/DocsTroubleshooting-Loading.md b/Mobile Music Assistant/DocsTroubleshooting-Loading.md new file mode 100644 index 0000000..93879b0 --- /dev/null +++ b/Mobile Music Assistant/DocsTroubleshooting-Loading.md @@ -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 diff --git a/Mobile Music Assistant/HelpersAudioPlayerEnvironment.swift b/Mobile Music Assistant/HelpersAudioPlayerEnvironment.swift new file mode 100644 index 0000000..ae7425f --- /dev/null +++ b/Mobile Music Assistant/HelpersAudioPlayerEnvironment.swift @@ -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 } + } +} diff --git a/Mobile Music Assistant/Info.plist.md b/Mobile Music Assistant/Info.plist.md new file mode 100644 index 0000000..690fa41 --- /dev/null +++ b/Mobile Music Assistant/Info.plist.md @@ -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 +UIBackgroundModes + + audio + +``` + +## Full Info.plist Example + +```xml + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIBackgroundModes + + audio + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + +``` + +## 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. diff --git a/Mobile Music Assistant/Mobile_Music_AssistantApp.swift b/Mobile Music Assistant/Mobile_Music_AssistantApp.swift new file mode 100644 index 0000000..c8cdb67 --- /dev/null +++ b/Mobile Music Assistant/Mobile_Music_AssistantApp.swift @@ -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) + } + } +} diff --git a/Mobile Music Assistant/ModelsMAModels.swift b/Mobile Music Assistant/ModelsMAModels.swift new file mode 100644 index 0000000..0b0b2ec --- /dev/null +++ b/Mobile Music Assistant/ModelsMAModels.swift @@ -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(as type: T.Type) throws -> T { + let data = try JSONEncoder().encode(self) + return try JSONDecoder().decode(T.self, from: data) + } +} diff --git a/Mobile Music Assistant/ServicesMAAudioPlayer.swift b/Mobile Music Assistant/ServicesMAAudioPlayer.swift new file mode 100644 index 0000000..3bd2534 --- /dev/null +++ b/Mobile Music Assistant/ServicesMAAudioPlayer.swift @@ -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 + } +} diff --git a/Mobile Music Assistant/ServicesMAAuthManager.swift b/Mobile Music Assistant/ServicesMAAuthManager.swift new file mode 100644 index 0000000..95eb70a --- /dev/null +++ b/Mobile Music Assistant/ServicesMAAuthManager.swift @@ -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)") + } + } +} diff --git a/Mobile Music Assistant/ServicesMALibraryManager.swift b/Mobile Music Assistant/ServicesMALibraryManager.swift new file mode 100644 index 0000000..c5ef8b2 --- /dev/null +++ b/Mobile Music Assistant/ServicesMALibraryManager.swift @@ -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) + } +} diff --git a/Mobile Music Assistant/ServicesMAPlayerManager.swift b/Mobile Music Assistant/ServicesMAPlayerManager.swift new file mode 100644 index 0000000..283df0b --- /dev/null +++ b/Mobile Music Assistant/ServicesMAPlayerManager.swift @@ -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? + + // 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) + } +} diff --git a/Mobile Music Assistant/ServicesMAService.swift b/Mobile Music Assistant/ServicesMAService.swift new file mode 100644 index 0000000..d3c6174 --- /dev/null +++ b/Mobile Music Assistant/ServicesMAService.swift @@ -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") + } +} diff --git a/Mobile Music Assistant/ServicesMAWebSocketClient.swift b/Mobile Music Assistant/ServicesMAWebSocketClient.swift new file mode 100644 index 0000000..fd47c21 --- /dev/null +++ b/Mobile Music Assistant/ServicesMAWebSocketClient.swift @@ -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] = [:] + private let requestQueue = DispatchQueue(label: "com.musicassistant.requests") + + // Event stream + private var eventContinuation: AsyncStream.Continuation? + private(set) var eventStream: AsyncStream + + // Reconnection + private var reconnectTask: Task? + 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.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( + _ 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) + } + } +} diff --git a/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift b/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift new file mode 100644 index 0000000..cfe5d1e --- /dev/null +++ b/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift @@ -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: 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) } + ) + } +} diff --git a/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift new file mode 100644 index 0000000..a364b63 --- /dev/null +++ b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift @@ -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 } + ) +} diff --git a/Mobile Music Assistant/ViewsComponentsMiniPlayerView.swift b/Mobile Music Assistant/ViewsComponentsMiniPlayerView.swift new file mode 100644 index 0000000..d4d010b --- /dev/null +++ b/Mobile Music Assistant/ViewsComponentsMiniPlayerView.swift @@ -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()) +} diff --git a/Mobile Music Assistant/ViewsComponentsPlayerPickerView.swift b/Mobile Music Assistant/ViewsComponentsPlayerPickerView.swift new file mode 100644 index 0000000..fae5551 --- /dev/null +++ b/Mobile Music Assistant/ViewsComponentsPlayerPickerView.swift @@ -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 } + ) +} diff --git a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift new file mode 100644 index 0000000..6ef671e --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift @@ -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()) + } +} diff --git a/Mobile Music Assistant/ViewsLibraryAlbumsView.swift b/Mobile Music Assistant/ViewsLibraryAlbumsView.swift new file mode 100644 index 0000000..86cc8b0 --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryAlbumsView.swift @@ -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()) + } +} diff --git a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift new file mode 100644 index 0000000..88e7362 --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift @@ -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()) + } +} diff --git a/Mobile Music Assistant/ViewsLibraryArtistsView.swift b/Mobile Music Assistant/ViewsLibraryArtistsView.swift new file mode 100644 index 0000000..a935e21 --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryArtistsView.swift @@ -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()) + } +} diff --git a/Mobile Music Assistant/ViewsLibraryLibraryView.swift b/Mobile Music Assistant/ViewsLibraryLibraryView.swift new file mode 100644 index 0000000..86ad6c7 --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryLibraryView.swift @@ -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()) +} diff --git a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift new file mode 100644 index 0000000..a89757a --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift @@ -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()) + } +} diff --git a/Mobile Music Assistant/ViewsLibraryPlaylistsView.swift b/Mobile Music Assistant/ViewsLibraryPlaylistsView.swift new file mode 100644 index 0000000..8c3c471 --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryPlaylistsView.swift @@ -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()) + } +} diff --git a/Mobile Music Assistant/ViewsLibrarySearchView.swift b/Mobile Music Assistant/ViewsLibrarySearchView.swift new file mode 100644 index 0000000..6a548d4 --- /dev/null +++ b/Mobile Music Assistant/ViewsLibrarySearchView.swift @@ -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? + + 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(_ shape: S) { + _path = { rect in + shape.path(in: rect) + } + } + + func path(in rect: CGRect) -> Path { + _path(rect) + } +} + +#Preview { + SearchView() + .environment(MAService()) +} diff --git a/Mobile Music Assistant/ViewsLocalPlayerView.swift b/Mobile Music Assistant/ViewsLocalPlayerView.swift new file mode 100644 index 0000000..c17e432 --- /dev/null +++ b/Mobile Music Assistant/ViewsLocalPlayerView.swift @@ -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()) +} diff --git a/Mobile Music Assistant/ViewsLoginView.swift b/Mobile Music Assistant/ViewsLoginView.swift new file mode 100644 index 0000000..253d786 --- /dev/null +++ b/Mobile Music Assistant/ViewsLoginView.swift @@ -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()) +} + diff --git a/Mobile Music Assistant/ViewsMainTabView.swift b/Mobile Music Assistant/ViewsMainTabView.swift new file mode 100644 index 0000000..dcaffc7 --- /dev/null +++ b/Mobile Music Assistant/ViewsMainTabView.swift @@ -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()) +} diff --git a/Mobile Music Assistant/ViewsPlayerView.swift b/Mobile Music Assistant/ViewsPlayerView.swift new file mode 100644 index 0000000..03b31e8 --- /dev/null +++ b/Mobile Music Assistant/ViewsPlayerView.swift @@ -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()) + } +} diff --git a/Mobile Music Assistant/ViewsRootView.swift b/Mobile Music Assistant/ViewsRootView.swift new file mode 100644 index 0000000..72d8212 --- /dev/null +++ b/Mobile Music Assistant/ViewsRootView.swift @@ -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()) +}