diff --git a/Mobile Music Assistant.xcodeproj/project.pbxproj b/Mobile Music Assistant.xcodeproj/project.pbxproj index 4ad8e17..87f6f05 100644 --- a/Mobile Music Assistant.xcodeproj/project.pbxproj +++ b/Mobile Music Assistant.xcodeproj/project.pbxproj @@ -10,18 +10,66 @@ 2616AF4E2F876BEF00CB210E /* ServicesMAStoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */; }; 2616AF502F87782600CB210E /* Donations.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 2616AF4F2F87782600CB210E /* Donations.storekit */; }; 2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */; }; + 269ECE562F92A07000444B14 /* MobileMAShared in Frameworks */ = {isa = PBXBuildFile; productRef = 269ECE552F92A07000444B14 /* MobileMAShared */; }; + 269ECE582F92A08300444B14 /* MobileMAShared in Frameworks */ = {isa = PBXBuildFile; productRef = 269ECE572F92A08300444B14 /* MobileMAShared */; }; + 269ECE5A2F92A24900444B14 /* MobileMALiveActivityExtension.appex in CopyFiles */ = {isa = PBXBuildFile; fileRef = 26DA6F7C2F928B2100849EC7 /* MobileMALiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; }; + 26DA6F7F2F928B2100849EC7 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26DA6F7E2F928B2100849EC7 /* WidgetKit.framework */; }; + 26DA6F812F928B2100849EC7 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26DA6F802F928B2100849EC7 /* SwiftUI.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXCopyFilesBuildPhase section */ + 269ECE592F92A21100444B14 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 269ECE5A2F92A24900444B14 /* MobileMALiveActivityExtension.appex in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicesMAStoreManager.swift; sourceTree = ""; }; 2616AF4F2F87782600CB210E /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = ""; }; 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerQueueView.swift; sourceTree = ""; }; + 269ECE4D2F929A9800444B14 /* Mobile​MAShared */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = "Mobile​MAShared"; sourceTree = ""; }; 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerNowPlayingView.swift; sourceTree = ""; }; + 26DA6F7C2F928B2100849EC7 /* MobileMALiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MobileMALiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 26DA6F7E2F928B2100849EC7 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 26DA6F802F928B2100849EC7 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 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 PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 269ECE4C2F9295BB00444B14 /* Exceptions for "MobileMALiveActivity" folder in "Mobile Music Assistant" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + MALiveActivityAttributes.swift, + ); + target = 26ED92602F759EEA0025419D /* Mobile Music Assistant */; + }; + 26DA6F902F928B2200849EC7 /* Exceptions for "MobileMALiveActivity" folder in "MobileMALiveActivityExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 26DA6F7B2F928B2100849EC7 /* MobileMALiveActivityExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ + 26DA6F822F928B2100849EC7 /* MobileMALiveActivity */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 269ECE4C2F9295BB00444B14 /* Exceptions for "MobileMALiveActivity" folder in "Mobile Music Assistant" target */, + 26DA6F902F928B2200849EC7 /* Exceptions for "MobileMALiveActivity" folder in "MobileMALiveActivityExtension" target */, + ); + path = MobileMALiveActivity; + sourceTree = ""; + }; 26ED92632F759EEA0025419D /* Mobile Music Assistant */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "Mobile Music Assistant"; @@ -30,21 +78,44 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 26DA6F792F928B2100849EC7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 26DA6F812F928B2100849EC7 /* SwiftUI.framework in Frameworks */, + 269ECE582F92A08300444B14 /* MobileMAShared in Frameworks */, + 26DA6F7F2F928B2100849EC7 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 26ED925E2F759EEA0025419D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 269ECE562F92A07000444B14 /* MobileMAShared in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 26DA6F7D2F928B2100849EC7 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 26DA6F7E2F928B2100849EC7 /* WidgetKit.framework */, + 26DA6F802F928B2100849EC7 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 26ED92582F759EEA0025419D = { isa = PBXGroup; children = ( + 269ECE4D2F929A9800444B14 /* Mobile​MAShared */, 2616AF4F2F87782600CB210E /* Donations.storekit */, 26ED92632F759EEA0025419D /* Mobile Music Assistant */, + 26DA6F822F928B2100849EC7 /* MobileMALiveActivity */, + 26DA6F7D2F928B2100849EC7 /* Frameworks */, 26ED92622F759EEA0025419D /* Products */, 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */, 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */, @@ -56,6 +127,7 @@ isa = PBXGroup; children = ( 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */, + 26DA6F7C2F928B2100849EC7 /* MobileMALiveActivityExtension.appex */, ); name = Products; sourceTree = ""; @@ -63,6 +135,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 26DA6F7B2F928B2100849EC7 /* MobileMALiveActivityExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 26DA6F912F928B2200849EC7 /* Build configuration list for PBXNativeTarget "MobileMALiveActivityExtension" */; + buildPhases = ( + 26DA6F782F928B2100849EC7 /* Sources */, + 26DA6F792F928B2100849EC7 /* Frameworks */, + 26DA6F7A2F928B2100849EC7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 26DA6F822F928B2100849EC7 /* MobileMALiveActivity */, + ); + name = MobileMALiveActivityExtension; + packageProductDependencies = ( + 269ECE572F92A08300444B14 /* MobileMAShared */, + ); + productName = MobileMALiveActivityExtension; + productReference = 26DA6F7C2F928B2100849EC7 /* MobileMALiveActivityExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 26ED92602F759EEA0025419D /* Mobile Music Assistant */ = { isa = PBXNativeTarget; buildConfigurationList = 26ED926C2F759EEB0025419D /* Build configuration list for PBXNativeTarget "Mobile Music Assistant" */; @@ -70,6 +165,7 @@ 26ED925D2F759EEA0025419D /* Sources */, 26ED925E2F759EEA0025419D /* Frameworks */, 26ED925F2F759EEA0025419D /* Resources */, + 269ECE592F92A21100444B14 /* CopyFiles */, ); buildRules = ( ); @@ -80,6 +176,7 @@ ); name = "Mobile Music Assistant"; packageProductDependencies = ( + 269ECE552F92A07000444B14 /* MobileMAShared */, ); productName = "Mobile Music Assistant"; productReference = 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */; @@ -95,6 +192,9 @@ LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 2640; TargetAttributes = { + 26DA6F7B2F928B2100849EC7 = { + CreatedOnToolsVersion = 26.4; + }; 26ED92602F759EEA0025419D = { CreatedOnToolsVersion = 26.4; }; @@ -112,17 +212,28 @@ ); mainGroup = 26ED92582F759EEA0025419D; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 269ECE4E2F929FE100444B14 /* XCLocalSwiftPackageReference "MobileMAShared" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 26ED92622F759EEA0025419D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 26ED92602F759EEA0025419D /* Mobile Music Assistant */, + 26DA6F7B2F928B2100849EC7 /* MobileMALiveActivityExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 26DA6F7A2F928B2100849EC7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 26ED925F2F759EEA0025419D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -134,6 +245,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 26DA6F782F928B2100849EC7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 26ED925D2F759EEA0025419D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -147,6 +265,66 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 26DA6F8E2F928B2200849EC7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = EKFHUHT63T; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MobileMALiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MobileMALiveActivity; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant.MobileMALiveActivity"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 26DA6F8F2F928B2200849EC7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = EKFHUHT63T; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MobileMALiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MobileMALiveActivity; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant.MobileMALiveActivity"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 26ED926A2F759EEB0025419D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -200,7 +378,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -258,7 +436,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -278,7 +456,10 @@ DEVELOPMENT_TEAM = EKFHUHT63T; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "Mobile MA"; + INFOPLIST_FILE = "Mobile-Music-Assistant-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -315,7 +496,10 @@ DEVELOPMENT_TEAM = EKFHUHT63T; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "Mobile MA"; + INFOPLIST_FILE = "Mobile-Music-Assistant-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -345,6 +529,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 26DA6F912F928B2200849EC7 /* Build configuration list for PBXNativeTarget "MobileMALiveActivityExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 26DA6F8E2F928B2200849EC7 /* Debug */, + 26DA6F8F2F928B2200849EC7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 26ED925C2F759EEA0025419D /* Build configuration list for PBXProject "Mobile Music Assistant" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -364,6 +557,26 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 269ECE4E2F929FE100444B14 /* XCLocalSwiftPackageReference "MobileMAShared" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = MobileMAShared; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 269ECE552F92A07000444B14 /* MobileMAShared */ = { + isa = XCSwiftPackageProductDependency; + package = 269ECE4E2F929FE100444B14 /* XCLocalSwiftPackageReference "MobileMAShared" */; + productName = MobileMAShared; + }; + 269ECE572F92A08300444B14 /* MobileMAShared */ = { + isa = XCSwiftPackageProductDependency; + package = 269ECE4E2F929FE100444B14 /* XCLocalSwiftPackageReference "MobileMAShared" */; + productName = MobileMAShared; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 26ED92592F759EEA0025419D /* Project object */; } diff --git a/Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist b/Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist index d8c2d53..b4afda1 100644 --- a/Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ orderHint 0 + MobileMALiveActivityExtension.xcscheme_^#shared#^_ + + orderHint + 1 + SuppressBuildableAutocreation diff --git a/Mobile Music Assistant/HelpersMANavigationDestination.swift b/Mobile Music Assistant/HelpersMANavigationDestination.swift index adc94b4..cd45664 100644 --- a/Mobile Music Assistant/HelpersMANavigationDestination.swift +++ b/Mobile Music Assistant/HelpersMANavigationDestination.swift @@ -13,6 +13,7 @@ enum MANavigationDestination: Hashable { case album(MAAlbum) case playlist(MAPlaylist) case podcast(MAPodcast) + case genre(MAGenre) } /// ViewModifier to apply all navigation destinations consistently @@ -31,6 +32,9 @@ struct MANavigationDestinations: ViewModifier { .navigationDestination(for: MAPodcast.self) { podcast in PodcastDetailView(podcast: podcast) } + .navigationDestination(for: MAGenre.self) { genre in + GenreDetailView(genre: genre) + } .navigationDestination(for: MANavigationDestination.self) { destination in switch destination { case .artist(let artist): @@ -41,6 +45,8 @@ struct MANavigationDestinations: ViewModifier { PlaylistDetailView(playlist: playlist) case .podcast(let podcast): PodcastDetailView(podcast: podcast) + case .genre(let genre): + GenreDetailView(genre: genre) } } } diff --git a/Mobile Music Assistant/Localizable.xcstrings b/Mobile Music Assistant/Localizable.xcstrings index 533103d..1cc11d3 100644 --- a/Mobile Music Assistant/Localizable.xcstrings +++ b/Mobile Music Assistant/Localizable.xcstrings @@ -1317,6 +1317,10 @@ } } }, + "Genres" : { + "comment" : "Title of the genres tab in the library view.", + "isCommentAutoGenerated" : true + }, "Group \"%@\" with \"%@\"?" : { "localizations" : { "de" : { @@ -1771,6 +1775,14 @@ "comment" : "A title for a view that shows when a user has no favorite songs.", "isCommentAutoGenerated" : true }, + "No Genres" : { + "comment" : "A title for a view that shows when a user has no genres in their library.", + "isCommentAutoGenerated" : true + }, + "No Items" : { + "comment" : "A label displayed when a genre has no items.", + "isCommentAutoGenerated" : true + }, "No Players Found" : { "localizations" : { "de" : { @@ -1969,6 +1981,10 @@ } } }, + "Nothing found for this genre" : { + "comment" : "A description displayed when a genre has no items.", + "isCommentAutoGenerated" : true + }, "Now Playing" : { "extractionState" : "stale", "localizations" : { @@ -2014,6 +2030,10 @@ } } }, + "Other" : { + "comment" : "A section for items that don't fit into the \"Artists\" or \"Albums\" section.", + "isCommentAutoGenerated" : true + }, "Play" : { "localizations" : { "de" : { @@ -2969,6 +2989,10 @@ } } }, + "Your library doesn't contain any genres yet" : { + "comment" : "A description of the content of the \"No Genres\" view.", + "isCommentAutoGenerated" : true + }, "Your library doesn't contain any playlists yet" : { "localizations" : { "de" : { diff --git a/Mobile Music Assistant/ModelsMAModels.swift b/Mobile Music Assistant/ModelsMAModels.swift index 9952139..46a1121 100644 --- a/Mobile Music Assistant/ModelsMAModels.swift +++ b/Mobile Music Assistant/ModelsMAModels.swift @@ -469,6 +469,35 @@ struct MAPodcast: Codable, Identifiable, Hashable { } } +// MARK: - Genre + +struct MAGenre: Codable, Identifiable, Hashable { + let uri: String + let name: String + let metadata: MediaItemMetadata? + + var id: String { uri } + var imageUrl: String? { metadata?.thumbImage?.path } + var imageProvider: String? { metadata?.thumbImage?.provider } + + enum CodingKeys: String, CodingKey { + case uri, name, metadata + } + + init(uri: String, name: String, metadata: MediaItemMetadata? = nil) { + self.uri = uri + self.name = name + self.metadata = metadata + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + uri = try c.decode(String.self, forKey: .uri) + name = try c.decode(String.self, forKey: .name) + metadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) + } +} + // MARK: - Repeat Mode enum RepeatMode: String, Codable, CaseIterable { diff --git a/Mobile Music Assistant/ServicesMALibraryManager.swift b/Mobile Music Assistant/ServicesMALibraryManager.swift index 70d402e..47d0a28 100644 --- a/Mobile Music Assistant/ServicesMALibraryManager.swift +++ b/Mobile Music Assistant/ServicesMALibraryManager.swift @@ -23,6 +23,7 @@ final class MALibraryManager { private(set) var albums: [MAAlbum] = [] private(set) var playlists: [MAPlaylist] = [] private(set) var podcasts: [MAPodcast] = [] + private(set) var genres: [MAGenre] = [] // Pagination private var artistsOffset = 0 @@ -39,6 +40,7 @@ final class MALibraryManager { private(set) var isLoadingAlbums = false private(set) var isLoadingPlaylists = false private(set) var isLoadingPodcasts = false + private(set) var isLoadingGenres = false /// URIs currently marked as favorites — source of truth for UI. /// Populated from decoded model data, then mutated optimistically on toggle. @@ -152,6 +154,7 @@ final class MALibraryManager { albums = [] playlists = [] podcasts = [] + genres = [] favoriteURIs = [] artistsOffset = 0 albumArtistsOffset = 0 @@ -397,6 +400,27 @@ final class MALibraryManager { logger.info("Loaded \(loaded.count) podcasts") } + // MARK: - Genres + + func loadGenres(refresh: Bool = false) async throws { + guard !isLoadingGenres else { return } + guard genres.isEmpty || refresh else { return } + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + + isLoadingGenres = true + defer { isLoadingGenres = false } + + logger.info("Loading genres") + let loaded = try await service.getGenres() + genres = loaded.sorted { $0.name < $1.name } + logger.info("Loaded \(loaded.count) genres") + } + + func browseGenre(genreUri: String) async throws -> [MAMediaItem] { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + return try await service.browseGenre(genreUri: genreUri) + } + func getPodcastEpisodes(podcastUri: String) async throws -> [MAMediaItem] { guard let service else { throw MAWebSocketClient.ClientError.notConnected } logger.info("Loading episodes for podcast \(podcastUri)") diff --git a/Mobile Music Assistant/ServicesMALiveActivityManager.swift b/Mobile Music Assistant/ServicesMALiveActivityManager.swift new file mode 100644 index 0000000..ba4b8e5 --- /dev/null +++ b/Mobile Music Assistant/ServicesMALiveActivityManager.swift @@ -0,0 +1,78 @@ +// +// ServicesMALiveActivityManager.swift +// Mobile Music Assistant +// + +import ActivityKit +import Foundation +import MobileMAShared +import OSLog + +private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "LiveActivity") + +/// Manages the Now Playing Live Activity lifecycle. +@Observable +final class MALiveActivityManager { + + private var currentActivity: Activity? + + init() { + // End any orphaned activities left over from previous sessions or format changes. + Task { + for orphan in Activity.activities { + await orphan.end(dismissalPolicy: .immediate) + } + } + } + + // MARK: - Public Interface + + /// Start or update the Live Activity with current playback state. + func update(trackTitle: String, artistName: String, artworkData: Data?, isPlaying: Bool, playerName: String) { + let state = MusicActivityAttributes.ContentState( + trackTitle: trackTitle, + artistName: artistName, + artworkData: artworkData, + isPlaying: isPlaying, + playerName: playerName + ) + + if let activity = currentActivity { + Task { + await activity.update(ActivityContent(state: state, staleDate: nil)) + logger.debug("Updated live activity: \(trackTitle)") + } + } else { + start(state: state) + } + } + + /// End the Live Activity immediately. + func end() { + guard let activity = currentActivity else { return } + currentActivity = nil + Task { + await activity.end(dismissalPolicy: .immediate) + logger.debug("Ended live activity") + } + } + + // MARK: - Private + + private func start(state: MusicActivityAttributes.ContentState) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + logger.info("Live Activities not enabled on this device") + return + } + do { + let activity = try Activity.request( + attributes: MusicActivityAttributes(), + content: ActivityContent(state: state, staleDate: nil) + ) + currentActivity = activity + logger.info("Started live activity: \(state.trackTitle)") + } catch { + logger.error("Failed to start live activity: \(error.localizedDescription)") + } + } +} diff --git a/Mobile Music Assistant/ServicesMAPlayerManager.swift b/Mobile Music Assistant/ServicesMAPlayerManager.swift index 4b7b848..2c1a16c 100644 --- a/Mobile Music Assistant/ServicesMAPlayerManager.swift +++ b/Mobile Music Assistant/ServicesMAPlayerManager.swift @@ -7,6 +7,7 @@ import Foundation import OSLog +import UIKit private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "PlayerManager") @@ -21,6 +22,7 @@ final class MAPlayerManager { private weak var service: MAService? private var eventTask: Task? + let liveActivityManager = MALiveActivityManager() // MARK: - Initialization @@ -84,6 +86,7 @@ final class MAPlayerManager { await MainActor.run { players[player.playerId] = player logger.debug("Updated player: \(player.name) state=\(player.state.rawValue) item=\(player.currentItem?.name ?? "nil")") + updateLiveActivity() } } catch { logger.error("Failed to decode player_updated event: \(error)") @@ -98,6 +101,7 @@ final class MAPlayerManager { await MainActor.run { playerQueues[queue.queueId] = queue logger.debug("Updated queue state for player \(queue.queueId), current: \(queue.currentItem?.name ?? "nil")") + updateLiveActivity() } return } @@ -140,6 +144,119 @@ final class MAPlayerManager { } } + // MARK: - Live Activity + + /// Finds the best currently-playing player and pushes its state to the Live Activity. + /// Spawns a Task to fetch artwork with auth before updating. + private func updateLiveActivity() { + let playing = players.values + .filter { $0.state == .playing } + .first { $0.currentItem != nil || playerQueues[$0.playerId]?.currentItem != nil } + ?? players.values.first { $0.state == .playing } + + guard let player = playing else { + liveActivityManager.end() + return + } + + guard let item = playerQueues[player.playerId]?.currentItem ?? player.currentItem else { + liveActivityManager.end() + return + } + + let media = item.mediaItem + let trackTitle = item.name.isEmpty ? (media?.name ?? "Unknown Track") : item.name + let artistName = media?.artists?.first?.name ?? "" + let isPlaying = player.state == .playing + let playerName = player.name + let imagePath = media?.imageUrl + let imageProvider = media?.imageProvider + + logger.debug("updateLiveActivity: track='\(trackTitle)' imagePath=\(imagePath ?? "nil")") + + // Update immediately so the live activity appears without waiting for artwork. + liveActivityManager.update( + trackTitle: trackTitle, + artistName: artistName, + artworkData: nil, + isPlaying: isPlaying, + playerName: playerName + ) + + // Then fetch artwork in background and refresh. + let capturedService = service + Task { + let artworkData = await Self.fetchArtworkData(path: imagePath, provider: imageProvider, service: capturedService) + logger.debug("fetchArtworkData result: \(artworkData != nil ? "\(artworkData!.count) bytes" : "nil")") + guard let artworkData else { return } + liveActivityManager.update( + trackTitle: trackTitle, + artistName: artistName, + artworkData: artworkData, + isPlaying: isPlaying, + playerName: playerName + ) + } + } + + /// Fetches artwork as small JPEG data for the Live Activity. + /// Checks the app's ImageCache at sizes the app normally loads (512, 64) before + /// falling back to a fresh network download at size 128 with auth. + private static func fetchArtworkData(path: String?, provider: String?, service: MAService?) async -> Data? { + guard let path, !path.isEmpty else { + logger.debug("fetchArtworkData: no image path") + return nil + } + guard let service else { + logger.debug("fetchArtworkData: service is nil") + return nil + } + + // Check cache at sizes the app commonly loads + for size in [512, 64] { + guard let url = service.imageProxyURL(path: path, provider: provider, size: size) else { continue } + let key = ImageCache.shared.cacheKey(for: url) + if let img = ImageCache.shared.memoryImage(for: key) { + logger.debug("fetchArtworkData: memory cache hit at size \(size)") + return resizeAndEncode(img) + } + if let img = await Task.detached(priority: .userInitiated) { ImageCache.shared.diskImage(for: key) }.value { + logger.debug("fetchArtworkData: disk cache hit at size \(size)") + return resizeAndEncode(img) + } + } + + // Not cached — download fresh at compact size with auth header + guard let downloadURL = service.imageProxyURL(path: path, provider: provider, size: 128) else { return nil } + logger.debug("fetchArtworkData: downloading from \(downloadURL)") + var request = URLRequest(url: downloadURL) + if let token = service.authManager.currentToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } else { + logger.warning("fetchArtworkData: no auth token available") + } + do { + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger.debug("fetchArtworkData: HTTP \(status), \(data.count) bytes") + guard status == 200, let img = UIImage(data: data) else { return nil } + ImageCache.shared.store(img, data: data, for: ImageCache.shared.cacheKey(for: downloadURL)) + return resizeAndEncode(img) + } catch { + logger.error("fetchArtworkData: network error: \(error.localizedDescription)") + return nil + } + } + + private static func resizeAndEncode(_ image: UIImage) -> Data? { + // ActivityKit ContentState limit is 4 KB total (Data fields are base64 in the payload). + // 40×40 JPEG at 0.3 quality ≈ 400–700 bytes, well within limits. + let size = CGSize(width: 40, height: 40) + let renderer = UIGraphicsImageRenderer(size: size) + let scaled = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) } + return scaled.jpegData(compressionQuality: 0.3) + } + // MARK: - Data Loading /// Load all players and their queue states @@ -175,6 +292,7 @@ final class MAPlayerManager { playerQueues[pid] = queue } logger.info("Loaded queue states for \(queueResults.count) players") + updateLiveActivity() } } diff --git a/Mobile Music Assistant/ServicesMAService.swift b/Mobile Music Assistant/ServicesMAService.swift index 3c8969f..b7a8663 100644 --- a/Mobile Music Assistant/ServicesMAService.swift +++ b/Mobile Music Assistant/ServicesMAService.swift @@ -351,6 +351,50 @@ final class MAService { ) } + /// Get genres + func getGenres() async throws -> [MAGenre] { + logger.debug("Fetching genres") + return try await webSocketClient.sendCommand( + "music/genres/library_items", + resultType: [MAGenre].self + ) + } + + /// Browse items under a genre URI. + /// MA returns provider sub-folders at the first level, so we auto-expand + /// them with a second browse pass to surface actual artists/albums. + func browseGenre(genreUri: String) async throws -> [MAMediaItem] { + logger.debug("Browsing genre \(genreUri)") + let firstLevel = try await webSocketClient.sendCommand( + "music/browse", + args: ["uri": genreUri], + resultType: [MAMediaItem].self + ) + + // If first level already contains real media items, return them. + let realItems = firstLevel.filter { + guard let t = $0.mediaType else { return false } + return t != .unknown + } + if !realItems.isEmpty { return realItems } + + // Otherwise these are sub-folders (providers) — browse each one. + var allItems: [MAMediaItem] = [] + var seen = Set() + for folder in firstLevel { + let items = (try? await webSocketClient.sendCommand( + "music/browse", + args: ["uri": folder.uri], + resultType: [MAMediaItem].self + )) ?? [] + for item in items where seen.insert(item.uri).inserted { + allItems.append(item) + } + } + logger.debug("Genre browse returned \(allItems.count) items after expanding \(firstLevel.count) folders") + return allItems + } + /// Get radio stations func getRadios() async throws -> [MAMediaItem] { logger.debug("Fetching radios") diff --git a/Mobile Music Assistant/ViewsLibraryGenresView.swift b/Mobile Music Assistant/ViewsLibraryGenresView.swift new file mode 100644 index 0000000..625b504 --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryGenresView.swift @@ -0,0 +1,201 @@ +// +// GenresView.swift +// Mobile Music Assistant +// +// Created by Sven Hanold on 17.04.26. +// + +import SwiftUI + +// MARK: - Genres List + +struct GenresView: View { + @Environment(MAService.self) private var service + @State private var errorMessage: String? + @State private var showError = false + + private var genres: [MAGenre] { service.libraryManager.genres } + private var isLoading: Bool { service.libraryManager.isLoadingGenres } + + var body: some View { + List(genres) { genre in + NavigationLink(value: genre) { + HStack(spacing: 12) { + Image(systemName: "guitars") + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 36, height: 36) + .background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + Text(genre.name.capitalized) + .font(.body) + } + .padding(.vertical, 2) + } + } + .listStyle(.plain) + .overlay { + if genres.isEmpty && isLoading { + ProgressView() + } else if genres.isEmpty && !isLoading { + ContentUnavailableView( + "No Genres", + systemImage: "guitars", + description: Text("Your library doesn't contain any genres yet") + ) + } + } + .refreshable { + await loadGenres(refresh: true) + } + .task { + if genres.isEmpty { + await loadGenres(refresh: true) + } + } + .alert("Error", isPresented: $showError) { + Button("OK", role: .cancel) { } + } message: { + if let errorMessage { Text(errorMessage) } + } + } + + private func loadGenres(refresh: Bool) async { + do { + try await service.libraryManager.loadGenres(refresh: refresh) + } catch { + errorMessage = error.localizedDescription + showError = true + } + } +} + +// MARK: - Genre Detail + +struct GenreDetailView: View { + @Environment(MAService.self) private var service + let genre: MAGenre + + @State private var items: [MAMediaItem] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showError = false + + private var artists: [MAMediaItem] { items.filter { $0.mediaType == .artist }.sorted { $0.name < $1.name } } + private var albums: [MAMediaItem] { items.filter { $0.mediaType == .album }.sorted { $0.name < $1.name } } + private var others: [MAMediaItem] { items.filter { $0.mediaType != .artist && $0.mediaType != .album } } + + var body: some View { + List { + if !artists.isEmpty { + Section("Artists") { + ForEach(artists) { item in + let artist = MAArtist(uri: item.uri, name: item.name, + imageUrl: item.imageUrl, imageProvider: item.imageProvider) + NavigationLink(value: artist) { + GenreItemRow(item: item, icon: "music.mic") + } + } + } + } + if !albums.isEmpty { + Section("Albums") { + ForEach(albums) { item in + let album = MAAlbum(uri: item.uri, name: item.name, + artists: item.artists, + imageUrl: item.imageUrl, imageProvider: item.imageProvider) + NavigationLink(value: album) { + GenreItemRow(item: item, icon: "square.stack") + } + } + } + } + if !others.isEmpty { + Section("Other") { + ForEach(others) { item in + GenreItemRow(item: item, icon: "music.note") + } + } + } + } + .navigationTitle(genre.name.capitalized) + .navigationBarTitleDisplayMode(.large) + .overlay { + if isLoading { + ProgressView() + } else if items.isEmpty && !isLoading { + ContentUnavailableView( + "No Items", + systemImage: "guitars", + description: Text("Nothing found for this genre") + ) + } + } + .task { await loadItems() } + .refreshable { await loadItems() } + .alert("Error", isPresented: $showError) { + Button("OK", role: .cancel) { } + } message: { + if let errorMessage { Text(errorMessage) } + } + } + + private func loadItems() async { + isLoading = true + do { + items = try await service.libraryManager.browseGenre(genreUri: genre.uri) + } catch { + errorMessage = error.localizedDescription + showError = true + } + isLoading = false + } +} + +// MARK: - Genre Item Row + +private struct GenreItemRow: View { + @Environment(MAService.self) private var service + let item: MAMediaItem + let icon: String + + var body: some View { + HStack(spacing: 12) { + CachedAsyncImage(url: service.imageProxyURL( + path: item.imageUrl, + provider: item.imageProvider, + size: 64 + )) { image in + image.resizable().aspectRatio(contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray.opacity(0.2)) + .overlay { + Image(systemName: icon) + .foregroundStyle(.secondary) + } + } + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.body) + .lineLimit(1) + if let artists = item.artists, !artists.isEmpty { + Text(artists.map(\.name).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.vertical, 2) + } +} + +#Preview { + NavigationStack { + GenresView() + .environment(MAService()) + } +} diff --git a/Mobile Music Assistant/ViewsLibraryLibraryView.swift b/Mobile Music Assistant/ViewsLibraryLibraryView.swift index 4f14277..f840ae0 100644 --- a/Mobile Music Assistant/ViewsLibraryLibraryView.swift +++ b/Mobile Music Assistant/ViewsLibraryLibraryView.swift @@ -9,7 +9,7 @@ import SwiftUI import UIKit enum LibraryTab: CaseIterable { - case albumArtists, artists, albums, playlists, podcasts, radio + case albumArtists, artists, albums, playlists, genres, podcasts, radio var title: LocalizedStringKey { switch self { @@ -17,6 +17,7 @@ enum LibraryTab: CaseIterable { case .artists: return "Artists" case .albums: return "Albums" case .playlists: return "Playlists" + case .genres: return "Genres" case .podcasts: return "Podcasts" case .radio: return "Radio" } @@ -42,6 +43,7 @@ struct LibraryView: View { case .artists: ArtistsView() case .albums: AlbumsView() case .playlists: PlaylistsView() + case .genres: GenresView() case .podcasts: PodcastsView() case .radio: RadiosView() } diff --git a/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift b/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift index 552424e..ec546f9 100644 --- a/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryPodcastDetailView.swift @@ -300,7 +300,7 @@ struct PodcastDetailView: View { private func loadEpisodes() async { isLoading = true do { - episodes = try await service.libraryManager.getPodcastEpisodes(podcastUri: podcast.uri) + episodes = try await service.libraryManager.getPodcastEpisodes(podcastUri: podcast.uri).reversed() isLoading = false } catch is CancellationError { return diff --git a/Mobile Music Assistant/ViewsMainTabView.swift b/Mobile Music Assistant/ViewsMainTabView.swift index 470f18a..9d2c140 100644 --- a/Mobile Music Assistant/ViewsMainTabView.swift +++ b/Mobile Music Assistant/ViewsMainTabView.swift @@ -13,6 +13,7 @@ private let syncLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Mobi struct MainTabView: View { @Environment(MAService.self) private var service + @Environment(\.scenePhase) private var scenePhase @State private var selectedTab: String = "library" var body: some View { @@ -49,6 +50,11 @@ struct MainTabView: View { .onDisappear { service.playerManager.stopListening() } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + Task { try? await service.playerManager.loadPlayers() } + } + } } } @@ -93,7 +99,7 @@ struct PlayerListView: View { var body: some View { NavigationStack { Group { - if isLoading { + if isLoading && !hasContent { ProgressView() } else if let errorMessage { ContentUnavailableView( diff --git a/Mobile-Music-Assistant-Info.plist b/Mobile-Music-Assistant-Info.plist new file mode 100644 index 0000000..2716686 --- /dev/null +++ b/Mobile-Music-Assistant-Info.plist @@ -0,0 +1,8 @@ + + + + + NSSupportsLiveActivities + + + diff --git a/MobileMALiveActivity/Assets.xcassets/AccentColor.colorset/Contents.json b/MobileMALiveActivity/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/MobileMALiveActivity/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MobileMALiveActivity/Assets.xcassets/AppIcon.appiconset/Contents.json b/MobileMALiveActivity/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/MobileMALiveActivity/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/MobileMALiveActivity/Assets.xcassets/Contents.json b/MobileMALiveActivity/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/MobileMALiveActivity/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MobileMALiveActivity/Assets.xcassets/WidgetBackground.colorset/Contents.json b/MobileMALiveActivity/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/MobileMALiveActivity/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MobileMALiveActivity/Info.plist b/MobileMALiveActivity/Info.plist new file mode 100644 index 0000000..08e79cc --- /dev/null +++ b/MobileMALiveActivity/Info.plist @@ -0,0 +1,16 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/MobileMALiveActivity/MALiveActivityAttributes.swift b/MobileMALiveActivity/MALiveActivityAttributes.swift new file mode 100644 index 0000000..5442947 --- /dev/null +++ b/MobileMALiveActivity/MALiveActivityAttributes.swift @@ -0,0 +1,8 @@ +// +// MALiveActivityAttributes.swift +// MobileMALiveActivity +// +// Re-exports MusicActivityAttributes from the shared package. +// + +@_exported import MobileMAShared diff --git a/MobileMALiveActivity/MobileMALiveActivityBundle.swift b/MobileMALiveActivity/MobileMALiveActivityBundle.swift new file mode 100644 index 0000000..476f6ae --- /dev/null +++ b/MobileMALiveActivity/MobileMALiveActivityBundle.swift @@ -0,0 +1,16 @@ +// +// MobileMALiveActivityBundle.swift +// MobileMALiveActivity +// +// Created by Sven Hanold on 17.04.26. +// + +import WidgetKit +import SwiftUI + +@main +struct MobileMALiveActivityBundle: WidgetBundle { + var body: some Widget { + MobileMALiveActivityLiveActivity() + } +} diff --git a/MobileMALiveActivity/MobileMALiveActivityLiveActivity.swift b/MobileMALiveActivity/MobileMALiveActivityLiveActivity.swift new file mode 100644 index 0000000..d1069f7 --- /dev/null +++ b/MobileMALiveActivity/MobileMALiveActivityLiveActivity.swift @@ -0,0 +1,173 @@ +// +// MobileMALiveActivityLiveActivity.swift +// MobileMALiveActivity +// + +import ActivityKit +import MobileMAShared +import SwiftUI +import UIKit +import WidgetKit + +private let activityTeal = Color(red: 0.0, green: 0.82, blue: 0.75) + +// MARK: - Artwork View + +private struct ArtworkView: View { + let artworkData: Data? + let size: CGFloat + let cornerRadius: CGFloat + let isPlaying: Bool + + var body: some View { + Group { + if let artworkData, let uiImage = UIImage(data: artworkData) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + fallbackIcon + } + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } + + private var fallbackIcon: some View { + ZStack { + activityTeal.opacity(0.2) + Image(systemName: "speaker.wave.3.fill") + .symbolEffect( + .variableColor.iterative.dimInactiveLayers.reversing, + isActive: isPlaying + ) + .font(.system(size: size * 0.4, weight: .semibold)) + .foregroundStyle(activityTeal) + } + } +} + +// MARK: - Widget + +struct MobileMALiveActivityLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: MusicActivityAttributes.self) { context in + LockScreenView(state: context.state) + .activityBackgroundTint(activityTeal.opacity(0.2)) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + ArtworkView( + artworkData: context.state.artworkData, + size: 50, + cornerRadius: 10, + isPlaying: context.state.isPlaying + ) + .padding(.leading, 4) + } + DynamicIslandExpandedRegion(.trailing) { + Image(systemName: context.state.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.title) + .foregroundStyle(activityTeal) + .padding(.trailing, 4) + } + DynamicIslandExpandedRegion(.center) { + VStack(alignment: .leading, spacing: 2) { + Text(context.state.trackTitle) + .font(.headline) + .lineLimit(1) + Text(context.state.artistName) + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + DynamicIslandExpandedRegion(.bottom) { + HStack(spacing: 4) { + Image(systemName: "hifispeaker.fill") + .font(.caption2) + .foregroundStyle(activityTeal) + Text(context.state.playerName) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 8) + } + } compactLeading: { + ArtworkView( + artworkData: context.state.artworkData, + size: 28, + cornerRadius: 6, + isPlaying: context.state.isPlaying + ) + .padding(.leading, 2) + } compactTrailing: { + Image(systemName: context.state.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.body) + .foregroundStyle(activityTeal) + .padding(.trailing, 2) + } minimal: { + ArtworkView( + artworkData: context.state.artworkData, + size: 24, + cornerRadius: 12, + isPlaying: context.state.isPlaying + ) + } + .keylineTint(activityTeal) + } + } +} + +// MARK: - Lock Screen View + +private struct LockScreenView: View { + let state: MusicActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 12) { + ArtworkView(artworkData: state.artworkData, size: 54, cornerRadius: 10, isPlaying: state.isPlaying) + + VStack(alignment: .leading, spacing: 2) { + Text(state.trackTitle.isEmpty ? "Now Playing" : state.trackTitle) + .font(.headline) + .lineLimit(1) + if !state.artistName.isEmpty { + Text(state.artistName) + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(.secondary) + } + HStack(spacing: 4) { + Image(systemName: "hifispeaker.fill") + .font(.caption2) + .foregroundStyle(activityTeal) + Text(state.playerName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: state.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(activityTeal) + } + .padding(16) + } +} + +// MARK: - Preview + +#Preview("Notification", as: .content, using: MusicActivityAttributes()) { + MobileMALiveActivityLiveActivity() +} contentStates: { + MusicActivityAttributes.ContentState( + trackTitle: "Bohemian Rhapsody", artistName: "Queen", + artworkData: nil, isPlaying: true, playerName: "Living Room") + MusicActivityAttributes.ContentState( + trackTitle: "Bohemian Rhapsody", artistName: "Queen", + artworkData: nil, isPlaying: false, playerName: "Living Room") +} diff --git a/MobileMAShared/Package.swift b/MobileMAShared/Package.swift new file mode 100644 index 0000000..77fcf13 --- /dev/null +++ b/MobileMAShared/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "MobileMAShared", + platforms: [.iOS(.v18)], + products: [ + .library(name: "MobileMAShared", targets: ["MobileMAShared"]) + ], + targets: [ + .target( + name: "MobileMAShared", + path: "Sources/MobileMAShared" + ) + ] +) diff --git a/MobileMAShared/Sources/MobileMAShared/MusicActivityAttributes.swift b/MobileMAShared/Sources/MobileMAShared/MusicActivityAttributes.swift new file mode 100644 index 0000000..5a075a7 --- /dev/null +++ b/MobileMAShared/Sources/MobileMAShared/MusicActivityAttributes.swift @@ -0,0 +1,22 @@ +import ActivityKit +import Foundation + +public struct MusicActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable, Sendable { + public var trackTitle: String + public var artistName: String + public var artworkData: Data? // Small JPEG pre-fetched by the main app + public var isPlaying: Bool + public var playerName: String + + public init(trackTitle: String, artistName: String, artworkData: Data?, isPlaying: Bool, playerName: String) { + self.trackTitle = trackTitle + self.artistName = artistName + self.artworkData = artworkData + self.isPlaying = isPlaying + self.playerName = playerName + } + } + + public init() {} +}