diff --git a/Mobile Music Assistant.xcodeproj/project.pbxproj b/Mobile Music Assistant.xcodeproj/project.pbxproj index 7d42fa4..eecbdc1 100644 --- a/Mobile Music Assistant.xcodeproj/project.pbxproj +++ b/Mobile Music Assistant.xcodeproj/project.pbxproj @@ -6,7 +6,12 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ + 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerNowPlayingView.swift; sourceTree = ""; }; 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -34,6 +39,7 @@ children = ( 26ED92632F759EEA0025419D /* Mobile Music Assistant */, 26ED92622F759EEA0025419D /* Products */, + 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */, ); sourceTree = ""; }; @@ -119,6 +125,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -269,12 +276,16 @@ PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 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"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -301,12 +312,16 @@ PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 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"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; diff --git a/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme b/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme new file mode 100644 index 0000000..a6ae343 --- /dev/null +++ b/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 fadb802..d8c2d53 100644 --- a/Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mobile Music Assistant.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,13 @@ 0 + SuppressBuildableAutocreation + + 26ED92602F759EEA0025419D + + primary + + + diff --git a/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..a1c452c Binary files /dev/null and b/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Mobile Music Assistant/HelpersMANavigationDestination.swift b/Mobile Music Assistant/HelpersMANavigationDestination.swift index f8164bf..d3e4e43 100644 --- a/Mobile Music Assistant/HelpersMANavigationDestination.swift +++ b/Mobile Music Assistant/HelpersMANavigationDestination.swift @@ -12,7 +12,6 @@ enum MANavigationDestination: Hashable { case artist(MAArtist) case album(MAAlbum) case playlist(MAPlaylist) - case player(String) // playerId } /// ViewModifier to apply all navigation destinations consistently @@ -36,8 +35,6 @@ struct MANavigationDestinations: ViewModifier { AlbumDetailView(album: album) case .playlist(let playlist): PlaylistDetailView(playlist: playlist) - case .player(let playerId): - PlayerView(playerId: playerId) } } } diff --git a/Mobile Music Assistant/Mobile_Music_AssistantApp.swift b/Mobile Music Assistant/Mobile_Music_AssistantApp.swift index c8cdb67..b25e4b5 100644 --- a/Mobile Music Assistant/Mobile_Music_AssistantApp.swift +++ b/Mobile Music Assistant/Mobile_Music_AssistantApp.swift @@ -10,11 +10,13 @@ import SwiftUI @main struct Mobile_Music_AssistantApp: App { @State private var service = MAService() + @State private var themeManager = MAThemeManager() var body: some Scene { WindowGroup { RootView() .environment(service) + .environment(themeManager) } } } diff --git a/Mobile Music Assistant/ModelsMAModels.swift b/Mobile Music Assistant/ModelsMAModels.swift index 0b0b2ec..31c284a 100644 --- a/Mobile Music Assistant/ModelsMAModels.swift +++ b/Mobile Music Assistant/ModelsMAModels.swift @@ -14,12 +14,18 @@ struct MAPlayer: Codable, Identifiable, Hashable { let name: String let state: PlayerState let currentItem: MAQueueItem? - let volume: Int + let volume: Int? let powered: Bool let available: Bool - + /// ID of the sync leader this player follows. Empty if not synced or if this player IS the leader. + let syncLeader: String + /// IDs of players synced to this one. Non-empty only on the sync leader. + let groupChilds: [String] + var id: String { playerId } - + var isGroupLeader: Bool { !groupChilds.isEmpty } + var isSyncMember: Bool { !syncLeader.isEmpty } + enum CodingKeys: String, CodingKey { case playerId = "player_id" case name @@ -28,6 +34,34 @@ struct MAPlayer: Codable, Identifiable, Hashable { case volume = "volume_level" case powered case available + case syncLeader = "sync_leader" + case groupChilds = "group_childs" + } + + /// Resilient decoder: MA may omit or change fields across versions and event types. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + playerId = try c.decode(String.self, forKey: .playerId) + name = try c.decode(String.self, forKey: .name) + // Accept any state string; fall back to .idle for unknown values (e.g. "buffering") + let stateRaw = (try? c.decode(String.self, forKey: .state)) ?? "idle" + state = PlayerState(rawValue: stateRaw) ?? .idle + // Treat a failed sub-decode of currentItem as absent rather than throwing + currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem) + // volume_level may be Int or Double depending on MA version + if let i = try? c.decode(Int.self, forKey: .volume) { + volume = i + } else if let d = try? c.decode(Double.self, forKey: .volume) { + volume = Int(d) + } else { + volume = nil + } + // powered/available may be absent in some event payloads + powered = (try? c.decode(Bool.self, forKey: .powered)) ?? false + available = (try? c.decode(Bool.self, forKey: .available)) ?? true + // Sync fields — absent in many event payloads, default to empty + syncLeader = (try? c.decode(String.self, forKey: .syncLeader)) ?? "" + groupChilds = (try? c.decode([String].self, forKey: .groupChilds)) ?? [] } } @@ -46,9 +80,9 @@ struct MAQueueItem: Codable, Identifiable, Hashable { 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" @@ -56,6 +90,23 @@ struct MAQueueItem: Codable, Identifiable, Hashable { case duration case streamDetails = "stream_details" } + + /// Resilient decoder: treat sub-decode failures as absent rather than throwing. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + queueItemId = try c.decode(String.self, forKey: .queueItemId) + name = try c.decode(String.self, forKey: .name) + mediaItem = try? c.decodeIfPresent(MAMediaItem.self, forKey: .mediaItem) + streamDetails = try? c.decodeIfPresent(MAStreamDetails.self, forKey: .streamDetails) + // duration may be Int or Double + if let i = try? c.decode(Int.self, forKey: .duration) { + duration = i + } else if let d = try? c.decode(Double.self, forKey: .duration) { + duration = Int(d) + } else { + duration = nil + } + } } struct MAStreamDetails: Codable, Hashable { @@ -82,27 +133,121 @@ struct MAAudioFormat: Codable, Hashable { } } +// MARK: - Media Item Image + +/// One entry in `metadata.images` as returned by Music Assistant. +struct MediaItemImage: Codable, Hashable { + let type: String? // e.g. "thumb", "fanart" + let path: String // URL or server-local path + let provider: String? // provider key, e.g. "spotify", "filesystem" + let remotelyAccessible: Bool? + + enum CodingKeys: String, CodingKey { + case type, path, provider + case remotelyAccessible = "remotely_accessible" + } +} + +/// The `metadata` object on every MA MediaItem. +struct MediaItemMetadata: Codable, Hashable { + let images: [MediaItemImage]? + let description: String? + let cacheChecksum: String? + + enum CodingKeys: String, CodingKey { + case images + case description + case cacheChecksum = "cache_checksum" + } + + init(images: [MediaItemImage]? = nil, description: String? = nil, cacheChecksum: String? = nil) { + self.images = images + self.description = description + self.cacheChecksum = cacheChecksum + } +} + +private extension MediaItemMetadata { + /// First image of type "thumb", or the first image of any type. + var thumbImage: MediaItemImage? { + images?.first(where: { $0.type == "thumb" }) ?? images?.first + } +} + // MARK: - Media Models struct MAMediaItem: Codable, Identifiable, Hashable { let uri: String let name: String - let mediaType: MediaType + let mediaType: MediaType? let artists: [MAArtist]? let album: MAAlbum? - let imageUrl: String? + let metadata: MediaItemMetadata? let duration: Int? - + var id: String { uri } - + var imageUrl: String? { metadata?.thumbImage?.path } + var imageProvider: String? { metadata?.thumbImage?.provider } + enum CodingKeys: String, CodingKey { - case uri - case name + case uri, name, duration, artists, album, metadata case mediaType = "media_type" - case artists - case album - case imageUrl = "image" - case duration + case image // Direct image field from search results + } + + init(uri: String, name: String, mediaType: MediaType? = nil, artists: [MAArtist]? = nil, album: MAAlbum? = nil, imageUrl: String? = nil, duration: Int? = nil) { + self.uri = uri; self.name = name; self.mediaType = mediaType + self.artists = artists; self.album = album; self.duration = duration + self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: nil, remotelyAccessible: nil)], cacheChecksum: nil) } + } + + 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) + + // Media type is critical - decode it first + let mediaTypeString = try? c.decodeIfPresent(String.self, forKey: .mediaType) + mediaType = mediaTypeString.flatMap(MediaType.init) + + // Try to decode duration (can be Int or Double) + if let i = try? c.decode(Int.self, forKey: .duration) { + duration = i + } else if let d = try? c.decode(Double.self, forKey: .duration) { + duration = Int(d) + } else { + duration = nil + } + + // Artists array - be very forgiving + artists = try? c.decodeIfPresent([MAArtist].self, forKey: .artists) + + // Album - be very forgiving + album = try? c.decodeIfPresent(MAAlbum.self, forKey: .album) + + // Try to decode metadata first + var decodedMetadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) + + // If metadata is missing, try to get image from direct "image" field (search results) + if decodedMetadata == nil { + // Try to decode the image field - it can be null, missing, or an object + if let imageObj = try? c.decodeIfPresent(MediaItemImage.self, forKey: .image) { + decodedMetadata = MediaItemMetadata(images: [imageObj], cacheChecksum: nil) + } + } + + metadata = decodedMetadata + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(uri, forKey: .uri) + try c.encode(name, forKey: .name) + try c.encodeIfPresent(mediaType?.rawValue, forKey: .mediaType) + try c.encodeIfPresent(artists, forKey: .artists) + try c.encodeIfPresent(album, forKey: .album) + try c.encodeIfPresent(duration, forKey: .duration) + try c.encodeIfPresent(metadata, forKey: .metadata) } } @@ -112,23 +257,63 @@ enum MediaType: String, Codable { case artist case playlist case radio + case audiobook + case podcast + case podcastEpisode = "podcast_episode" + case unknown } struct MAArtist: Codable, Identifiable, Hashable { let uri: String let name: String - let imageUrl: String? + let metadata: MediaItemMetadata? let sortName: String? let musicbrainzId: String? - + var id: String { uri } - + var imageUrl: String? { metadata?.thumbImage?.path } + var imageProvider: String? { metadata?.thumbImage?.provider } + enum CodingKeys: String, CodingKey { - case uri - case name - case imageUrl = "image" + case uri, name, metadata case sortName = "sort_name" case musicbrainzId = "musicbrainz_id" + case image // Direct image field + } + + init(uri: String, name: String, imageUrl: String? = nil, imageProvider: String? = nil, sortName: String? = nil, musicbrainzId: String? = nil) { + self.uri = uri; self.name = name + self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) } + self.sortName = sortName; self.musicbrainzId = musicbrainzId + } + + 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) + sortName = try? c.decodeIfPresent(String.self, forKey: .sortName) + musicbrainzId = try? c.decodeIfPresent(String.self, forKey: .musicbrainzId) + + // Try to decode metadata first + var decodedMetadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) + + // If metadata is missing, try to get image from direct "image" field + if decodedMetadata == nil { + if let imageObj = try? c.decodeIfPresent(MediaItemImage.self, forKey: .image) { + decodedMetadata = MediaItemMetadata(images: [imageObj], cacheChecksum: nil) + } + } + + metadata = decodedMetadata + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(uri, forKey: .uri) + try c.encode(name, forKey: .name) + try c.encodeIfPresent(sortName, forKey: .sortName) + try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId) + try c.encodeIfPresent(metadata, forKey: .metadata) } } @@ -136,17 +321,50 @@ struct MAAlbum: Codable, Identifiable, Hashable { let uri: String let name: String let artists: [MAArtist]? - let imageUrl: String? + let metadata: MediaItemMetadata? let year: Int? - + var id: String { uri } - + var imageUrl: String? { metadata?.thumbImage?.path } + var imageProvider: String? { metadata?.thumbImage?.provider } + enum CodingKeys: String, CodingKey { - case uri - case name - case artists - case imageUrl = "image" - case year + case uri, name, artists, metadata, year + case image // Direct image field + } + + init(uri: String, name: String, artists: [MAArtist]? = nil, imageUrl: String? = nil, imageProvider: String? = nil, year: Int? = nil) { + self.uri = uri; self.name = name; self.artists = artists; self.year = year + self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) } + } + + 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) + artists = try? c.decodeIfPresent([MAArtist].self, forKey: .artists) + year = try? c.decodeIfPresent(Int.self, forKey: .year) + + // Try to decode metadata first + var decodedMetadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) + + // If metadata is missing, try to get image from direct "image" field + if decodedMetadata == nil { + if let imageObj = try? c.decodeIfPresent(MediaItemImage.self, forKey: .image) { + decodedMetadata = MediaItemMetadata(images: [imageObj], cacheChecksum: nil) + } + } + + metadata = decodedMetadata + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(uri, forKey: .uri) + try c.encode(name, forKey: .name) + try c.encodeIfPresent(artists, forKey: .artists) + try c.encodeIfPresent(year, forKey: .year) + try c.encodeIfPresent(metadata, forKey: .metadata) } } @@ -154,18 +372,54 @@ struct MAPlaylist: Codable, Identifiable, Hashable { let uri: String let name: String let owner: String? - let imageUrl: String? + let metadata: MediaItemMetadata? let isEditable: Bool - + var id: String { uri } - + var imageUrl: String? { metadata?.thumbImage?.path } + var imageProvider: String? { metadata?.thumbImage?.provider } + enum CodingKeys: String, CodingKey { - case uri - case name - case owner - case imageUrl = "image" + case uri, name, owner, metadata case isEditable = "is_editable" } + + init(uri: String, name: String, owner: String? = nil, imageUrl: String? = nil, isEditable: Bool = false) { + self.uri = uri; self.name = name; self.owner = owner; self.isEditable = isEditable + self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: nil, remotelyAccessible: nil)], cacheChecksum: nil) } + } + + 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) + owner = try? c.decodeIfPresent(String.self, forKey: .owner) + isEditable = (try? c.decode(Bool.self, forKey: .isEditable)) ?? false + metadata = try? c.decodeIfPresent(MediaItemMetadata.self, forKey: .metadata) + } +} + +// MARK: - Player Queue State + +/// Represents the state of a player's queue, including the currently playing item. +/// Populated via `player_queues/get` and `queue_updated` events. +struct MAPlayerQueue: Codable { + let queueId: String + let currentItem: MAQueueItem? + let currentIndex: Int? + + enum CodingKeys: String, CodingKey { + case queueId = "queue_id" + case currentItem = "current_item" + case currentIndex = "current_index" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + queueId = try c.decode(String.self, forKey: .queueId) + currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem) + currentIndex = try? c.decodeIfPresent(Int.self, forKey: .currentIndex) + } } // MARK: - WebSocket Protocol Models @@ -185,14 +439,14 @@ struct MACommand: Encodable { struct MAResponse: Decodable { let messageId: String? let result: AnyCodable? - let errorCode: String? - let errorMessage: String? + let errorCode: Int? + let details: String? enum CodingKeys: String, CodingKey { case messageId = "message_id" case result case errorCode = "error_code" - case errorMessage = "error" + case details } } diff --git a/Mobile Music Assistant/ServicesMALibraryManager.swift b/Mobile Music Assistant/ServicesMALibraryManager.swift index 8f3e584..630b40a 100644 --- a/Mobile Music Assistant/ServicesMALibraryManager.swift +++ b/Mobile Music Assistant/ServicesMALibraryManager.swift @@ -41,6 +41,9 @@ final class MALibraryManager { // MARK: - Disk Cache + /// Increment this whenever the model format changes to invalidate stale caches. + private static let cacheVersion = 2 + private let cacheDirectory: URL = { let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let dir = caches.appendingPathComponent("MMLibrary", isDirectory: true) @@ -52,9 +55,24 @@ final class MALibraryManager { init(service: MAService?) { self.service = service + migrateIfNeeded() loadFromDisk() } + /// Clears all disk-cached library data when the model format changes. + private func migrateIfNeeded() { + let storedVersion = UserDefaults.standard.integer(forKey: "lib.cacheVersion") + guard storedVersion < Self.cacheVersion else { return } + + logger.info("Cache version mismatch (\(storedVersion) → \(Self.cacheVersion)), clearing library cache") + try? FileManager.default.removeItem(at: cacheDirectory) + try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + for key in ["lib.lastArtistsRefresh", "lib.lastAlbumsRefresh", "lib.lastPlaylistsRefresh"] { + UserDefaults.standard.removeObject(forKey: key) + } + UserDefaults.standard.set(Self.cacheVersion, forKey: "lib.cacheVersion") + } + func setService(_ service: MAService) { self.service = service } @@ -110,10 +128,10 @@ final class MALibraryManager { guard !isLoadingArtists else { return } guard let service else { throw MAWebSocketClient.ClientError.notConnected } + // For refresh, reset pagination counters but keep existing data visible until new data arrives + let fetchOffset = refresh ? 0 : artistsOffset if refresh { - artistsOffset = 0 hasMoreArtists = true - artists = [] } guard hasMoreArtists else { return } @@ -121,20 +139,25 @@ final class MALibraryManager { isLoadingArtists = true defer { isLoadingArtists = false } - logger.info("Loading artists (offset: \(self.artistsOffset))") + logger.info("Loading artists (offset: \(fetchOffset), refresh: \(refresh))") - let newArtists = try await service.getArtists(limit: pageSize, offset: artistsOffset) + let newArtists = try await service.getArtists(limit: pageSize, offset: fetchOffset) - if refresh { - artists = newArtists - } else { - artists.append(contentsOf: newArtists) + // DEBUG: log first artist's image state so we can trace artwork loading + if let a = newArtists.first { + logger.debug("DEBUG Artist[0] name=\(a.name) metadata=\(String(describing: a.metadata)) imageUrl=\(a.imageUrl ?? "nil") imageProvider=\(a.imageProvider ?? "nil")") } - artistsOffset += newArtists.count + // Replace or append atomically — no intermediate empty state + if refresh { + artists = newArtists + artistsOffset = newArtists.count + } else { + artists.append(contentsOf: newArtists) + artistsOffset += newArtists.count + } hasMoreArtists = newArtists.count >= pageSize - // Persist to disk after a full load or first page of refresh if refresh || artistsOffset <= pageSize { save(artists, "artists.json") lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh") @@ -159,10 +182,9 @@ final class MALibraryManager { guard !isLoadingAlbums else { return } guard let service else { throw MAWebSocketClient.ClientError.notConnected } + let fetchOffset = refresh ? 0 : albumsOffset if refresh { - albumsOffset = 0 hasMoreAlbums = true - albums = [] } guard hasMoreAlbums else { return } @@ -170,17 +192,17 @@ final class MALibraryManager { isLoadingAlbums = true defer { isLoadingAlbums = false } - logger.info("Loading albums (offset: \(self.albumsOffset))") + logger.info("Loading albums (offset: \(fetchOffset), refresh: \(refresh))") - let newAlbums = try await service.getAlbums(limit: pageSize, offset: albumsOffset) + let newAlbums = try await service.getAlbums(limit: pageSize, offset: fetchOffset) if refresh { albums = newAlbums + albumsOffset = newAlbums.count } else { albums.append(contentsOf: newAlbums) + albumsOffset += newAlbums.count } - - albumsOffset += newAlbums.count hasMoreAlbums = newAlbums.count >= pageSize if refresh || albumsOffset <= pageSize { diff --git a/Mobile Music Assistant/ServicesMAPlayerManager.swift b/Mobile Music Assistant/ServicesMAPlayerManager.swift index 283df0b..cae681d 100644 --- a/Mobile Music Assistant/ServicesMAPlayerManager.swift +++ b/Mobile Music Assistant/ServicesMAPlayerManager.swift @@ -16,6 +16,7 @@ final class MAPlayerManager { // MARK: - Properties private(set) var players: [String: MAPlayer] = [:] + private(set) var playerQueues: [String: MAPlayerQueue] = [:] private(set) var queues: [String: [MAQueueItem]] = [:] private weak var service: MAService? @@ -77,36 +78,43 @@ final class MAPlayerManager { 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)") + logger.debug("Updated player: \(player.name) state=\(player.state.rawValue) item=\(player.currentItem?.name ?? "nil")") } } catch { - logger.error("Failed to decode player update: \(error.localizedDescription)") + logger.error("Failed to decode player_updated event: \(error)") } } 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 { + guard let data = event.data else { return } + + // The event data IS the PlayerQueue object — decode it directly for current_item + if let queue = try? data.decode(as: MAPlayerQueue.self), !queue.queueId.isEmpty { + await MainActor.run { + playerQueues[queue.queueId] = queue + logger.debug("Updated queue state for player \(queue.queueId), current: \(queue.currentItem?.name ?? "nil")") + } return } - - // Reload queue for this player - guard let service else { return } - + + // Fallback: extract queue_id and fetch from API + guard let dict = data.value as? [String: Any], + let queueId = dict["queue_id"] as? String, + let service else { return } + do { - let items = try await service.getQueue(playerId: queueId) + let queue = try await service.getPlayerQueue(playerId: queueId) await MainActor.run { - queues[queueId] = items - logger.debug("Updated queue for player \(queueId): \(items.count) items") + playerQueues[queueId] = queue + logger.debug("Fetched queue state for player \(queueId), current: \(queue.currentItem?.name ?? "nil")") } } catch { - logger.error("Failed to reload queue: \(error.localizedDescription)") + logger.error("Failed to reload queue state: \(error.localizedDescription)") } } @@ -117,18 +125,40 @@ final class MAPlayerManager { // MARK: - Data Loading - /// Load all players + /// Load all players and their queue states func loadPlayers() async throws { - guard let service else { + 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) }) } + + // Concurrently fetch queue state for each player to get current_item + var queueResults: [String: MAPlayerQueue] = [:] + await withTaskGroup(of: (String, MAPlayerQueue?).self) { group in + for player in playerList { + let pid = player.playerId + group.addTask { + let queue = try? await service.getPlayerQueue(playerId: pid) + return (pid, queue) + } + } + for await (pid, queue) in group { + if let queue { queueResults[pid] = queue } + } + } + + await MainActor.run { + for (pid, queue) in queueResults { + playerQueues[pid] = queue + } + logger.info("Loaded queue states for \(queueResults.count) players") + } } /// Load queue for specific player @@ -189,6 +219,16 @@ final class MAPlayerManager { try await service.setVolume(playerId: playerId, level: level) } + func syncPlayer(playerId: String, targetPlayerId: String) async throws { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + try await service.syncPlayer(playerId: playerId, targetPlayerId: targetPlayerId) + } + + func unsyncPlayer(playerId: String) async throws { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + try await service.unsyncPlayer(playerId: playerId) + } + func playMedia(playerId: String, uri: String) async throws { guard let service else { throw MAWebSocketClient.ClientError.notConnected diff --git a/Mobile Music Assistant/ServicesMAService.swift b/Mobile Music Assistant/ServicesMAService.swift index d3c6174..5107c39 100644 --- a/Mobile Music Assistant/ServicesMAService.swift +++ b/Mobile Music Assistant/ServicesMAService.swift @@ -21,6 +21,10 @@ final class MAService { let libraryManager: MALibraryManager private(set) var isConnected = false + + // Cache for artist/album detail responses — keyed by URI, only stored when description is present + private var artistDetailCache: [String: MAArtist] = [:] + private var albumDetailCache: [String: MAAlbum] = [:] // MARK: - Initialization @@ -74,7 +78,7 @@ final class MAService { func getPlayers() async throws -> [MAPlayer] { logger.debug("Fetching players") return try await webSocketClient.sendCommand( - "players", + "players/all", resultType: [MAPlayer].self ) } @@ -124,6 +128,24 @@ final class MAService { ) } + /// Sync a player to a target (target becomes the sync leader) + func syncPlayer(playerId: String, targetPlayerId: String) async throws { + logger.debug("Syncing player \(playerId) to \(targetPlayerId)") + _ = try await webSocketClient.sendCommand( + "players/cmd/sync", + args: ["player_id": playerId, "target_player_id": targetPlayerId] + ) + } + + /// Remove a player from its sync group (or dissolve the group if called on the leader) + func unsyncPlayer(playerId: String) async throws { + logger.debug("Unsyncing player \(playerId)") + _ = try await webSocketClient.sendCommand( + "players/cmd/unsync", + args: ["player_id": playerId] + ) + } + /// Set volume (0-100) func setVolume(playerId: String, level: Int) async throws { let clampedLevel = max(0, min(100, level)) @@ -139,7 +161,17 @@ final class MAService { // MARK: - Queue - /// Get player queue + /// Get the queue state for a player (includes current_item) + func getPlayerQueue(playerId: String) async throws -> MAPlayerQueue { + logger.debug("Fetching queue state for player \(playerId)") + return try await webSocketClient.sendCommand( + "player_queues/get", + args: ["queue_id": playerId], + resultType: MAPlayerQueue.self + ) + } + + /// Get player queue items list func getQueue(playerId: String) async throws -> [MAQueueItem] { logger.debug("Fetching queue for player \(playerId)") return try await webSocketClient.sendCommand( @@ -153,7 +185,7 @@ final class MAService { 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", + "player_queues/play_media", args: [ "queue_id": playerId, "media": [uri] @@ -165,7 +197,7 @@ final class MAService { 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", + "player_queues/play_index", args: [ "queue_id": playerId, "index": index @@ -177,7 +209,7 @@ final class MAService { 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", + "player_queues/move_item", args: [ "queue_id": playerId, "queue_item_id": fromIndex, @@ -192,7 +224,7 @@ final class MAService { 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", + "music/artists/library_items", args: [ "limit": limit, "offset": offset @@ -205,7 +237,7 @@ final class MAService { 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", + "music/albums/library_items", args: [ "limit": limit, "offset": offset @@ -214,118 +246,205 @@ final class MAService { ) } + /// Get radio stations + func getRadios() async throws -> [MAMediaItem] { + logger.debug("Fetching radios") + return try await webSocketClient.sendCommand( + "music/radios/library_items", + resultType: [MAMediaItem].self + ) + } + /// Get playlists func getPlaylists() async throws -> [MAPlaylist] { logger.debug("Fetching playlists") return try await webSocketClient.sendCommand( - "music/playlists", + "music/playlists/library_items", resultType: [MAPlaylist].self ) } + /// Get full artist details (includes biography in metadata.description). + /// Results are cached in memory once biography data is available, so repeated + /// navigation is instant. Skips the cache if the previous fetch had no biography, + /// allowing the server time to enrich the metadata. + func getArtistDetail(artistUri: String) async throws -> MAArtist { + if let cached = artistDetailCache[artistUri], + cached.metadata?.description?.isEmpty == false { + return cached + } + logger.debug("Fetching artist detail for \(artistUri)") + guard let (provider, itemId) = parseMAUri(artistUri) else { + throw MAWebSocketClient.ClientError.serverError("Invalid artist URI: \(artistUri)") + } + let detail = try await webSocketClient.sendCommand( + "music/artists/get", + args: [ + "item_id": itemId, + "provider_instance_id_or_domain": provider + ], + resultType: MAArtist.self + ) + artistDetailCache[artistUri] = detail + return detail + } + + /// Get full album details (includes description in metadata.description). + /// Results are cached once description data is available. + func getAlbumDetail(albumUri: String) async throws -> MAAlbum { + if let cached = albumDetailCache[albumUri], + cached.metadata?.description?.isEmpty == false { + return cached + } + logger.debug("Fetching album detail for \(albumUri)") + guard let (provider, itemId) = parseMAUri(albumUri) else { + throw MAWebSocketClient.ClientError.serverError("Invalid album URI: \(albumUri)") + } + let detail = try await webSocketClient.sendCommand( + "music/albums/get", + args: [ + "item_id": itemId, + "provider_instance_id_or_domain": provider + ], + resultType: MAAlbum.self + ) + albumDetailCache[albumUri] = detail + return detail + } + + /// Get albums for an artist + func getArtistAlbums(artistUri: String) async throws -> [MAAlbum] { + logger.debug("Fetching albums for artist \(artistUri)") + guard let (provider, itemId) = parseMAUri(artistUri) else { + throw MAWebSocketClient.ClientError.serverError("Invalid artist URI: \(artistUri)") + } + return try await webSocketClient.sendCommand( + "music/artists/artist_albums", + args: [ + "item_id": itemId, + "provider_instance_id_or_domain": provider + ], + resultType: [MAAlbum].self + ) + } + /// Get album tracks func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] { logger.debug("Fetching tracks for album \(albumUri)") + guard let (provider, itemId) = parseMAUri(albumUri) else { + throw MAWebSocketClient.ClientError.serverError("Invalid album URI: \(albumUri)") + } return try await webSocketClient.sendCommand( - "music/album_tracks", - args: ["uri": albumUri], + "music/albums/album_tracks", + args: [ + "item_id": itemId, + "provider_instance_id_or_domain": provider + ], resultType: [MAMediaItem].self ) } /// Search library func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] { - logger.debug("Searching for '\(query)'") + logger.debug("🔍 Searching for '\(query)'") - var args: [String: Any] = ["search": query] + var args: [String: Any] = ["search_query": query] if let mediaTypes { args["media_types"] = mediaTypes.map { $0.rawValue } } - return try await webSocketClient.sendCommand( - "music/search", - args: args, - resultType: [MAMediaItem].self - ) + // Try to get the response + let response = try await webSocketClient.sendCommand("music/search", args: args) + + guard let result = response.result else { + logger.error("❌ Search returned no result") + return [] + } + + // Log raw result + if let jsonData = try? JSONEncoder().encode(result), + let jsonString = String(data: jsonData, encoding: .utf8) { + logger.debug("📦 Raw search response (first 500 chars): \(String(jsonString.prefix(500)))") + } + + do { + // Music Assistant returns search results categorized by type + struct SearchResults: Decodable { + let albums: [MAMediaItem]? + let tracks: [MAMediaItem]? + let artists: [MAMediaItem]? + let playlists: [MAMediaItem]? + let radios: [MAMediaItem]? + } + + let searchResults = try result.decode(as: SearchResults.self) + + // Combine all results into a single array + var allItems: [MAMediaItem] = [] + if let albums = searchResults.albums { allItems.append(contentsOf: albums) } + if let tracks = searchResults.tracks { allItems.append(contentsOf: tracks) } + if let artists = searchResults.artists { allItems.append(contentsOf: artists) } + if let playlists = searchResults.playlists { allItems.append(contentsOf: playlists) } + if let radios = searchResults.radios { allItems.append(contentsOf: radios) } + + logger.info("✅ Decoded \(allItems.count) search results (albums: \(searchResults.albums?.count ?? 0), tracks: \(searchResults.tracks?.count ?? 0), artists: \(searchResults.artists?.count ?? 0), radios: \(searchResults.radios?.count ?? 0))") + return allItems + + } catch let error { + logger.error("❌ Failed to decode search results: \(error)") + + // Log the actual structure for debugging + if let jsonData = try? JSONEncoder().encode(result), + let jsonString = String(data: jsonData, encoding: .utf8) { + logger.error("📦 Raw search response (first 1000 chars): \(String(jsonString.prefix(1000)))") + } + + // Return empty array instead of throwing + return [] + } + } + + /// Parse a Music Assistant URI into (provider, itemId) + /// MA URIs follow the format: scheme://media_type/item_id + private func parseMAUri(_ uri: String) -> (provider: String, itemId: String)? { + guard let url = URL(string: uri), + let provider = url.scheme, + let host = url.host else { return nil } + let itemId = url.path.isEmpty ? host : String(url.path.dropFirst()) + return (provider, itemId) } // MARK: - Image Proxy - - /// Build URL for image proxy - func imageProxyURL(path: String, size: Int = 256) -> URL? { - guard let serverURL = authManager.serverURL else { return nil } - + + /// Build URL for the MA image proxy. + /// + /// MA uses `/imageproxy` (not `/api/image_proxy`) and requires the `path` value to be + /// double-URL-encoded — equivalent to the frontend's + /// `encodeURIComponent(encodeURIComponent(img.path))`. + /// + /// - Parameters: + /// - path: The image path or remote URL from `MediaItemImage.path`. Nil returns nil. + /// - provider: The provider key from `MediaItemImage.provider`. + /// - size: Desired pixel size (width and height). + func imageProxyURL(path: String?, provider: String? = nil, size: Int = 256) -> URL? { + guard let path, !path.isEmpty, let serverURL = authManager.serverURL else { return nil } + + // Replicate JS encodeURIComponent (encodes everything except A–Z a–z 0–9 - _ . ! ~ * ' ( )) + let uriAllowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.!~*'()")) + let once = path.addingPercentEncoding(withAllowedCharacters: uriAllowed) ?? path + let twice = once.addingPercentEncoding(withAllowedCharacters: uriAllowed) ?? once + 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)) - ] - + components.path = "/imageproxy" + + // Build the query manually so URLComponents doesn't re-encode the already-encoded value + var queryParts = ["path=\(twice)", "size=\(size)"] + if let provider, !provider.isEmpty { + let encProvider = provider.addingPercentEncoding(withAllowedCharacters: uriAllowed) ?? provider + queryParts.append("provider=\(encProvider)") + } + components.percentEncodedQuery = queryParts.joined(separator: "&") 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/ServicesMAThemeManager.swift b/Mobile Music Assistant/ServicesMAThemeManager.swift index 5ee28b8..0a68e17 100644 --- a/Mobile Music Assistant/ServicesMAThemeManager.swift +++ b/Mobile Music Assistant/ServicesMAThemeManager.swift @@ -16,8 +16,8 @@ class MAThemeManager { } init() { - let savedValue = UserDefaults.standard.string(forKey: "appColorScheme") ?? "system" - colorScheme = AppColorScheme(rawValue: savedValue) ?? .system + let savedValue = UserDefaults.standard.string(forKey: "appColorScheme") ?? "dark" + colorScheme = AppColorScheme(rawValue: savedValue) ?? .dark } var preferredColorScheme: ColorScheme? { @@ -50,6 +50,17 @@ enum AppColorScheme: String, CaseIterable, Identifiable { } } + var description: String { + switch self { + case .system: + return "Follows your device's appearance" + case .light: + return "Always use light mode" + case .dark: + return "Always use dark mode" + } + } + var icon: String { switch self { case .system: @@ -74,3 +85,21 @@ extension EnvironmentValues { set { self[ThemeManagerKey.self] = newValue } } } +// MARK: - Theme Applied Modifier + +struct ThemeAppliedModifier: ViewModifier { + @Environment(\.themeManager) var themeManager + + func body(content: Content) -> some View { + content + .preferredColorScheme(themeManager.preferredColorScheme) + .id(themeManager.colorScheme) // Force refresh when theme changes + } +} + +extension View { + func applyTheme() -> some View { + modifier(ThemeAppliedModifier()) + } +} + diff --git a/Mobile Music Assistant/ServicesMAWebSocketClient.swift b/Mobile Music Assistant/ServicesMAWebSocketClient.swift index fd47c21..1cc96f1 100644 --- a/Mobile Music Assistant/ServicesMAWebSocketClient.swift +++ b/Mobile Music Assistant/ServicesMAWebSocketClient.swift @@ -100,17 +100,11 @@ final class MAWebSocketClient { /// 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 @@ -120,50 +114,77 @@ final class MAWebSocketClient { 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)") + logger.debug("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) + let task = session.webSocketTask(with: URLRequest(url: wsURL)) self.webSocketTask = task - - print("🔵 MAWebSocketClient.performConnect: Starting WebSocket task") task.resume() - // Start listening for messages - startReceiving() + do { + // MA sends a server-info message immediately on connect; receive and discard it + _ = try await task.receive() + logger.debug("Received server info") + + // Send auth command and wait for confirmation + if let authToken { + try await performAuth(task: task, token: authToken) + logger.info("Authenticated successfully") + } + + // Now safe to start the regular message loop + startReceiving() + connectionState = .connected + logger.info("Connected successfully") + + } catch { + task.cancel(with: .goingAway, reason: nil) + webSocketTask = nil + connectionState = .disconnected + throw error + } + } + + private func performAuth(task: URLSessionWebSocketTask, token: String) async throws { + let messageId = UUID().uuidString + let cmd = MACommand( + messageId: messageId, + command: "auth", + args: ["token": AnyCodable(token)] + ) + let data = try JSONEncoder().encode(cmd) + guard let json = String(data: data, encoding: .utf8) else { + throw ClientError.decodingError(NSError(domain: "Encoding", code: -1)) + } - connectionState = .connected - logger.info("Connected successfully") - print("✅ MAWebSocketClient.performConnect: Connection successful") + try await task.send(.string(json)) + + // Receive the auth response + let result = try await task.receive() + guard case .string(let responseText) = result, + let responseData = responseText.data(using: .utf8) else { + throw ClientError.serverError("Invalid auth response format") + } + + if let response = try? JSONDecoder().decode(MAResponse.self, from: responseData), + let errorCode = response.errorCode { + throw ClientError.serverError(response.details ?? "Authentication failed (code \(errorCode))") + } + // Non-error response means auth succeeded } /// Disconnect from server @@ -172,19 +193,21 @@ final class MAWebSocketClient { shouldReconnect = false reconnectTask?.cancel() reconnectTask = nil - - webSocketTask?.cancel(with: .goingAway, reason: nil) + + // Nil out BEFORE cancel so any in-flight receive callbacks see nil and exit early + let task = webSocketTask webSocketTask = nil - + connectionState = .disconnected + task?.cancel(with: .goingAway, reason: nil) + // Cancel all pending requests requestQueue.sync { - for (messageId, continuation) in pendingRequests { + for (_, continuation) in pendingRequests { continuation.resume(throwing: ClientError.notConnected) } pendingRequests.removeAll() } - - connectionState = .disconnected + eventContinuation?.finish() } @@ -192,17 +215,22 @@ final class MAWebSocketClient { 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() - + // Only continue if we are still connected to this same task + if self.webSocketTask === task { + self.startReceiving() + } + case .failure(let error): + // URLError.cancelled is expected during a clean disconnect — not a real error + let nsError = error as NSError + guard nsError.code != URLError.cancelled.rawValue else { return } logger.error("WebSocket receive error: \(error.localizedDescription)") self.handleDisconnection() } @@ -245,7 +273,7 @@ final class MAWebSocketClient { // Check for error if let errorCode = response.errorCode { - let errorMsg = response.errorMessage ?? errorCode + let errorMsg = response.details ?? "Error code: \(errorCode)" continuation.resume(throwing: ClientError.serverError(errorMsg)) } else { continuation.resume(returning: response) @@ -259,9 +287,14 @@ final class MAWebSocketClient { } private func handleDisconnection() { + // Idempotency guard — can be called from receive callback and disconnect() simultaneously + guard connectionState != .disconnected else { return } + connectionState = .disconnected + let task = webSocketTask webSocketTask = nil - + task?.cancel(with: .goingAway, reason: nil) + // Cancel pending requests requestQueue.sync { for (_, continuation) in pendingRequests { @@ -269,7 +302,7 @@ final class MAWebSocketClient { } pendingRequests.removeAll() } - + // Attempt reconnection if needed if shouldReconnect { scheduleReconnect(attempt: 1) @@ -372,8 +405,32 @@ final class MAWebSocketClient { } do { + // Debug: Log the raw result before decoding + if let jsonData = try? JSONEncoder().encode(result), + let jsonString = String(data: jsonData, encoding: .utf8) { + logger.debug("📦 Response result for '\(command)': \(jsonString)") + } + return try result.decode(as: T.self) } catch { + logger.error("❌ Failed to decode result for '\(command)': \(error.localizedDescription)") + + // Log more details about the decoding error + if let decodingError = error as? DecodingError { + switch decodingError { + case .dataCorrupted(let context): + logger.error("Data corrupted: \(context.debugDescription)") + case .keyNotFound(let key, let context): + logger.error("Key '\(key.stringValue)' not found: \(context.debugDescription)") + case .typeMismatch(let type, let context): + logger.error("Type mismatch for \(type): \(context.debugDescription)") + case .valueNotFound(let type, let context): + logger.error("Value not found for \(type): \(context.debugDescription)") + @unknown default: + logger.error("Unknown decoding error") + } + } + throw ClientError.decodingError(error) } } diff --git a/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift index abdf99d..ded29cc 100644 --- a/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift +++ b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift @@ -70,6 +70,9 @@ private struct PickerPlayerCard: View { let player: MAPlayer let onSelect: () -> Void + // Always read live state so the indicator reflects real-time changes + private var livePlayer: MAPlayer { service.playerManager.players[player.playerId] ?? player } + private var currentItem: MAQueueItem? { service.playerManager.playerQueues[player.playerId]?.currentItem } @@ -79,12 +82,12 @@ private struct PickerPlayerCard: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { - if player.state == .playing { + if livePlayer.state == .playing { Image(systemName: "waveform") .font(.caption) .foregroundStyle(.green) } - Text(player.name) + Text(livePlayer.name) .font(.headline) .foregroundStyle(.primary) .lineLimit(1) @@ -102,7 +105,7 @@ private struct PickerPlayerCard: View { .lineLimit(1) } } else { - Text(player.state == .off ? "Powered Off" : "No Track Playing") + Text(livePlayer.state == .off ? "Powered Off" : "No Track Playing") .font(.subheadline) .foregroundStyle(.tertiary) .lineLimit(1) @@ -148,13 +151,16 @@ private struct PickerGroupCard: View { let memberNames: [String] let onSelect: () -> Void + // Always read live state so the indicator reflects real-time changes + private var liveLeader: MAPlayer { service.playerManager.players[leader.playerId] ?? leader } + private var currentItem: MAQueueItem? { service.playerManager.playerQueues[leader.playerId]?.currentItem } private var mediaItem: MAMediaItem? { currentItem?.mediaItem } private var groupName: String { - ([leader.name] + memberNames).joined(separator: " + ") + ([liveLeader.name] + memberNames).joined(separator: " + ") } var body: some View { @@ -164,7 +170,7 @@ private struct PickerGroupCard: View { Image(systemName: "speaker.2.fill") .font(.caption) .foregroundStyle(.blue) - if leader.state == .playing { + if liveLeader.state == .playing { Image(systemName: "waveform") .font(.caption) .foregroundStyle(.green) @@ -187,7 +193,7 @@ private struct PickerGroupCard: View { .lineLimit(1) } } else { - Text(leader.state == .off ? "Powered Off" : "No Track Playing") + Text(liveLeader.state == .off ? "Powered Off" : "No Track Playing") .font(.subheadline) .foregroundStyle(.tertiary) .lineLimit(1) diff --git a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift index 6ef671e..c2e9f8b 100644 --- a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift @@ -9,7 +9,6 @@ import SwiftUI struct AlbumDetailView: View { @Environment(MAService.self) private var service - @Environment(\.audioPlayer) private var audioPlayer let album: MAAlbum @State private var tracks: [MAMediaItem] = [] @@ -18,39 +17,86 @@ struct AlbumDetailView: View { @State private var showError = false @State private var showPlayerPicker = false @State private var selectedPlayer: MAPlayer? + @State private var kenBurnsScale: CGFloat = 1.0 + @State private var completeAlbum: MAAlbum? + @State private var albumDescription: String? + @State private var isDescriptionExpanded = false private var players: [MAPlayer] { - Array(service.playerManager.players.values).sorted { $0.name < $1.name } + Array(service.playerManager.players.values) + .filter { $0.available } + .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 + ZStack { + // Blurred Background with Ken Burns Effect + backgroundArtwork + + // Content + ScrollView { + VStack(spacing: 24) { + // Album Header + albumHeader + + // Play Button + playButton + + Divider() + .background(Color.white.opacity(0.3)) + + // Album description + if let albumDescription { + descriptionSection(albumDescription) + } + + // Tracklist + if isLoading { + ProgressView() + .padding() + .tint(.white) + } else if tracks.isEmpty { + Text("No tracks found") + .foregroundStyle(.white.opacity(0.7)) + .padding() + } else { + trackList + } + + // Show when the album came from a provider-specific URI and the + // full library version is available + if let completeAlbum { + NavigationLink(value: completeAlbum) { + Label("Show complete album", systemImage: "music.note.list") + .font(.subheadline.bold()) + .foregroundStyle(.white.opacity(0.85)) + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + ) + } + .padding(.horizontal) + .padding(.bottom, 8) + } } } } .navigationTitle(album.name) .navigationBarTitleDisplayMode(.inline) + .toolbarColorScheme(.dark, for: .navigationBar) .task { - await loadTracks() + async let tracksLoad: () = loadTracks() + async let detailLoad: () = loadAlbumDetail() + _ = await (tracksLoad, detailLoad) + withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) { + kenBurnsScale = 1.15 + } } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } @@ -62,76 +108,105 @@ struct AlbumDetailView: View { .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) - } - } + onSelect: { player in + Task { await playAlbum(on: player) } } ) } } + // MARK: - Background Artwork + + @ViewBuilder + private var backgroundArtwork: some View { + GeometryReader { geometry in + CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 512)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width, height: geometry.size.height) + .scaleEffect(kenBurnsScale) + .blur(radius: 50) + .overlay { + // Dark gradient overlay for better text contrast + LinearGradient( + colors: [ + Color.black.opacity(0.7), + Color.black.opacity(0.5), + Color.black.opacity(0.7) + ], + startPoint: .top, + endPoint: .bottom + ) + } + .clipped() + } placeholder: { + Rectangle() + .fill( + LinearGradient( + colors: [Color(.systemGray6), Color(.systemGray5)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay { + Color.black.opacity(0.6) + } + } + } + .ignoresSafeArea() + } + // 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 { + CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 512)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { RoundedRectangle(cornerRadius: 12) - .fill(Color.gray.opacity(0.2)) - .frame(width: 250, height: 250) + .fill(Color.white.opacity(0.1)) .overlay { Image(systemName: "opticaldisc") .font(.system(size: 60)) - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.5)) } } + .frame(width: 250, height: 250) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.5), radius: 20, y: 10) // Album Info VStack(spacing: 8) { if let artists = album.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.title3) - .foregroundStyle(.secondary) + .fontWeight(.semibold) + .foregroundStyle(.white) .multilineTextAlignment(.center) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) } HStack { if let year = album.year { Text(String(year)) .font(.subheadline) - .foregroundStyle(.tertiary) + .foregroundStyle(.white.opacity(0.8)) } if !tracks.isEmpty { Text("•") - .foregroundStyle(.tertiary) + .foregroundStyle(.white.opacity(0.8)) Text("\(tracks.count) tracks") .font(.subheadline) - .foregroundStyle(.tertiary) + .foregroundStyle(.white.opacity(0.8)) } } + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) } .padding(.horizontal) } @@ -156,12 +231,24 @@ struct AlbumDetailView: View { .font(.headline) .frame(maxWidth: .infinity) .padding() - .background(Color.accentColor) + .background( + LinearGradient( + colors: [Color.white.opacity(0.3), Color.white.opacity(0.2)], + startPoint: .top, + endPoint: .bottom + ) + ) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: 10, y: 5) } .padding(.horizontal) .disabled(tracks.isEmpty || players.isEmpty) + .opacity((tracks.isEmpty || players.isEmpty) ? 0.5 : 1.0) } // MARK: - Track List @@ -170,7 +257,7 @@ struct AlbumDetailView: View { private var trackList: some View { LazyVStack(spacing: 0) { ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in - TrackRow(track: track, trackNumber: index + 1) + TrackRow(track: track, trackNumber: index + 1, useLightTheme: true) .contentShape(Rectangle()) .onTapGesture { if players.count == 1 { @@ -184,31 +271,95 @@ struct AlbumDetailView: View { if index < tracks.count - 1 { Divider() + .background(Color.white.opacity(0.2)) .padding(.leading, 60) } } } - .background(Color(.systemBackground)) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + ) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal) + .padding(.bottom, 24) } + // MARK: - Description Section + + @ViewBuilder + private func descriptionSection(_ text: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("About") + .font(.title2.bold()) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) + + Text(verbatim: text) + .font(.body) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(isDescriptionExpanded ? nil : 4) + .lineSpacing(3) + + Button { + withAnimation(.easeInOut(duration: 0.25)) { + isDescriptionExpanded.toggle() + } + } label: { + Text(isDescriptionExpanded ? "Show less" : "Show more") + .font(.subheadline.bold()) + .foregroundStyle(.white.opacity(0.6)) + } + } + .padding(.horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + } + // MARK: - Actions private func loadTracks() async { + print("🔵 AlbumDetailView: Loading tracks for album: \(album.name)") + print("🔵 AlbumDetailView: Album URI: \(album.uri)") isLoading = true errorMessage = nil do { tracks = try await service.libraryManager.getAlbumTracks(albumUri: album.uri) + print("✅ AlbumDetailView: Loaded \(tracks.count) tracks") isLoading = false } catch { + print("❌ AlbumDetailView: Failed to load tracks: \(error)") errorMessage = error.localizedDescription showError = true isLoading = false } + + // If this album came from a provider-specific URI (not the full library version), + // try to find the matching library album so we can offer a "Show complete album" link. + if let scheme = URL(string: album.uri)?.scheme, scheme != "library" { + completeAlbum = service.libraryManager.albums.first { + $0.name.caseInsensitiveCompare(album.name) == .orderedSame + && $0.uri.hasPrefix("library://") + && ($0.year == album.year || album.year == nil) + } + } } + private func loadAlbumDetail() async { + do { + let detail = try await service.getAlbumDetail(albumUri: album.uri) + if let desc = detail.metadata?.description, !desc.isEmpty { + albumDescription = desc + } + } catch { + // Description is optional — silently ignore if unavailable + } + } + private func playAlbum(on player: MAPlayer) async { do { try await service.playerManager.playMedia( @@ -221,28 +372,6 @@ struct AlbumDetailView: View { } } - 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( @@ -261,25 +390,27 @@ struct AlbumDetailView: View { struct TrackRow: View { let track: MAMediaItem let trackNumber: Int + var useLightTheme: Bool = false var body: some View { HStack(spacing: 12) { // Track Number Text("\(trackNumber)") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary) .frame(width: 30, alignment: .trailing) // Track Info VStack(alignment: .leading, spacing: 4) { Text(track.name) .font(.body) + .foregroundStyle(useLightTheme ? .white : .primary) .lineLimit(1) if let artists = track.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary) .lineLimit(1) } } @@ -290,7 +421,7 @@ struct TrackRow: View { if let duration = track.duration { Text(formatDuration(duration)) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary) } } .padding(.vertical, 8) diff --git a/Mobile Music Assistant/ViewsLibraryAlbumsView.swift b/Mobile Music Assistant/ViewsLibraryAlbumsView.swift index 86cc8b0..0eea620 100644 --- a/Mobile Music Assistant/ViewsLibraryAlbumsView.swift +++ b/Mobile Music Assistant/ViewsLibraryAlbumsView.swift @@ -21,12 +21,14 @@ struct AlbumsView: View { } private let columns = [ - GridItem(.adaptive(minimum: 160), spacing: 16) + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) ] var body: some View { ScrollView { - LazyVGrid(columns: columns, spacing: 16) { + LazyVGrid(columns: columns, spacing: 8) { ForEach(albums) { album in NavigationLink(value: album) { AlbumGridItem(album: album) @@ -40,21 +42,17 @@ struct AlbumsView: View { if isLoading { ProgressView() .gridCellColumns(columns.count) - .padding() + .padding(.horizontal, 12) + .padding(.vertical, 8) } } .padding() } - .navigationDestination(for: MAAlbum.self) { album in - AlbumDetailView(album: album) - } .refreshable { await loadAlbums(refresh: true) } .task { - if albums.isEmpty { - await loadAlbums(refresh: false) - } + await loadAlbums(refresh: !albums.isEmpty) } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } @@ -100,36 +98,28 @@ struct AlbumGridItem: View { let album: MAAlbum var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { // 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 { + CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 256)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.2)) - .frame(width: 160, height: 160) .overlay { Image(systemName: "opticaldisc") .font(.system(size: 40)) .foregroundStyle(.secondary) } } + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) // Album Info VStack(alignment: .leading, spacing: 2) { Text(album.name) - .font(.subheadline) + .font(.caption) .fontWeight(.medium) .lineLimit(2) .foregroundStyle(.primary) @@ -147,7 +137,7 @@ struct AlbumGridItem: View { .foregroundStyle(.tertiary) } } - .frame(width: 160, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) } } } diff --git a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift index f12afca..5b6218f 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift @@ -12,35 +12,58 @@ struct ArtistDetailView: View { let artist: MAArtist @State private var albums: [MAAlbum] = [] + @State private var biography: String? @State private var isLoading = true @State private var errorMessage: String? @State private var showError = false + @State private var kenBurnsScale: CGFloat = 1.0 + @State private var isBiographyExpanded = false var body: some View { - ScrollView { - VStack(spacing: 24) { - // Artist Header - artistHeader - - Divider() - - // Albums Section - if isLoading { - ProgressView() - .padding() - } else if albums.isEmpty { - Text("No albums found") - .foregroundStyle(.secondary) - .padding() - } else { - albumGrid + ZStack { + // Blurred Background with Ken Burns Effect + backgroundArtwork + + // Content + ScrollView { + VStack(spacing: 24) { + // Artist Header + artistHeader + + // Biography Section + if let biography { + biographySection(biography) + } + + Divider() + .background(Color.white.opacity(0.3)) + + // Albums Section + if isLoading { + ProgressView() + .padding() + .tint(.white) + } else if albums.isEmpty { + Text("No albums found") + .foregroundStyle(.white.opacity(0.7)) + .padding() + } else { + albumGrid + } } } } .navigationTitle(artist.name) .navigationBarTitleDisplayMode(.inline) + .toolbarColorScheme(.dark, for: .navigationBar) .task { - await loadAlbums() + async let albumsLoad: () = loadAlbums() + async let detailLoad: () = loadArtistDetail() + _ = await (albumsLoad, detailLoad) + // Start Ken Burns animation + withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) { + kenBurnsScale = 1.15 + } } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } @@ -51,39 +74,76 @@ struct ArtistDetailView: View { } } + // MARK: - Background Artwork + + @ViewBuilder + private var backgroundArtwork: some View { + GeometryReader { geometry in + CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 512)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width, height: geometry.size.height) + .scaleEffect(kenBurnsScale) + .blur(radius: 50) + .overlay { + // Dark gradient overlay for better text contrast + LinearGradient( + colors: [ + Color.black.opacity(0.7), + Color.black.opacity(0.5), + Color.black.opacity(0.7) + ], + startPoint: .top, + endPoint: .bottom + ) + } + .clipped() + } placeholder: { + Rectangle() + .fill( + LinearGradient( + colors: [Color(.systemGray6), Color(.systemGray5)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay { + Color.black.opacity(0.6) + } + } + } + .ignoresSafeArea() + } + // MARK: - Artist Header @ViewBuilder private var artistHeader: some View { VStack(spacing: 16) { - 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: 200, height: 200) - .clipShape(Circle()) - .shadow(radius: 10) - } else { + CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 512)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { Circle() - .fill(Color.gray.opacity(0.2)) - .frame(width: 200, height: 200) + .fill(Color.white.opacity(0.1)) .overlay { Image(systemName: "music.mic") .font(.system(size: 60)) - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.5)) } } + .frame(width: 200, height: 200) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.5), radius: 20, y: 10) if !albums.isEmpty { Text("\(albums.count) albums") .font(.subheadline) - .foregroundStyle(.secondary) + .fontWeight(.semibold) + .foregroundStyle(.white.opacity(0.8)) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) } } .padding(.top) @@ -96,6 +156,8 @@ struct ArtistDetailView: View { VStack(alignment: .leading, spacing: 12) { Text("Albums") .font(.title2.bold()) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) .padding(.horizontal) LazyVGrid( @@ -103,30 +165,77 @@ struct ArtistDetailView: View { spacing: 16 ) { ForEach(albums) { album in - NavigationLink(destination: AlbumDetailView(album: album)) { + NavigationLink(value: album) { ArtistAlbumCard(album: album, service: service) } .buttonStyle(.plain) } } .padding(.horizontal) + .padding(.bottom, 24) } } + // MARK: - Biography Section + + @ViewBuilder + private func biographySection(_ text: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("About") + .font(.title2.bold()) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) + + Text(verbatim: text) + .font(.body) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(isBiographyExpanded ? nil : 4) + .lineSpacing(3) + + Button { + withAnimation(.easeInOut(duration: 0.25)) { + isBiographyExpanded.toggle() + } + } label: { + Text(isBiographyExpanded ? "Show less" : "Show more") + .font(.subheadline.bold()) + .foregroundStyle(.white.opacity(0.6)) + } + } + .padding(.horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + } + // MARK: - Actions private func loadAlbums() async { + print("🔵 ArtistDetailView: Loading albums for artist: \(artist.name)") + print("🔵 ArtistDetailView: Artist URI: \(artist.uri)") isLoading = true errorMessage = nil do { albums = try await service.libraryManager.getArtistAlbums(artistUri: artist.uri) + print("✅ ArtistDetailView: Loaded \(albums.count) albums") isLoading = false } catch { + print("❌ ArtistDetailView: Failed to load albums: \(error)") errorMessage = error.localizedDescription showError = true isLoading = false } } + + private func loadArtistDetail() async { + do { + let detail = try await service.getArtistDetail(artistUri: artist.uri) + if let desc = detail.metadata?.description, !desc.isEmpty { + biography = desc + } + } catch { + // Biography is optional — silently ignore if unavailable + print("ℹ️ ArtistDetailView: Could not load artist detail: \(error)") + } + } } // MARK: - Artist Album Card @@ -137,40 +246,45 @@ private struct ArtistAlbumCard: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - 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)) - } - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } else { + CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 256)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.2)) - .aspectRatio(1, contentMode: .fit) + .fill(Color.white.opacity(0.1)) .overlay { Image(systemName: "opticaldisc") .font(.system(size: 36)) - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.5)) } } + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(color: .black.opacity(0.4), radius: 8, y: 4) Text(album.name) .font(.caption.bold()) .lineLimit(2) - .foregroundStyle(.primary) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.3), radius: 1, x: 0, y: 1) if let year = album.year { Text(String(year)) .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.7)) + .shadow(color: .black.opacity(0.3), radius: 1, x: 0, y: 1) } } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + ) } } diff --git a/Mobile Music Assistant/ViewsLibraryArtistsView.swift b/Mobile Music Assistant/ViewsLibraryArtistsView.swift index fdc36d2..38c694c 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistsView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistsView.swift @@ -22,7 +22,9 @@ struct ArtistsView: View { } private let columns = [ - GridItem(.adaptive(minimum: 80), spacing: 8) + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) ] /// Artists grouped by first letter; non-alphabetic names go under "#" @@ -42,55 +44,60 @@ struct ArtistsView: View { artistsByLetter.map { $0.0 } } + // Always show A–Z plus # so the sidebar has a consistent full height + private let allLetters: [String] = (65...90).map { String(UnicodeScalar($0)!) } + ["#"] + var body: some View { ScrollViewReader { proxy in - ZStack(alignment: .trailing) { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(artistsByLetter, id: \.0) { letter, letterArtists in - // Section header - Text(letter) - .font(.headline) - .fontWeight(.bold) - .foregroundStyle(.secondary) - .padding(.horizontal, 12) - .padding(.top, 10) - .padding(.bottom, 4) - .id(letter) + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(artistsByLetter, id: \.0) { letter, letterArtists in + // Section header — scroll target + Text(letter) + .font(.headline) + .fontWeight(.bold) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 4) + .id(letter) - // Grid of artists in this section - LazyVGrid(columns: columns, spacing: 8) { - ForEach(letterArtists) { artist in - NavigationLink(value: artist) { - ArtistGridItem(artist: artist) - } - .buttonStyle(.plain) - .task { - await loadMoreIfNeeded(currentItem: artist) - } + // Grid of artists in this section + LazyVGrid(columns: columns, spacing: 8) { + ForEach(letterArtists) { artist in + NavigationLink(value: artist) { + ArtistGridItem(artist: artist) + } + .buttonStyle(.plain) + .task { + await loadMoreIfNeeded(currentItem: artist) } } - .padding(.horizontal, 12) - .padding(.bottom, 4) - } - - if isLoading { - ProgressView() - .frame(maxWidth: .infinity) - .padding() } + .padding(.horizontal, 12) + .padding(.bottom, 4) } - // Right padding leaves room for the alphabet index - .padding(.trailing, 24) - } - // Floating alphabet index on the right edge - if !availableLetters.isEmpty { - AlphabetIndexView(letters: availableLetters) { letter in - proxy.scrollTo(letter, anchor: .top) + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding() } - .padding(.trailing, 2) } + .padding(.trailing, 28) + } + .overlay(alignment: .trailing) { + AlphabetIndexView( + letters: allLetters, + itemHeight: 17, + onSelect: { letter in + // Scroll to this letter's section, or the nearest one after it + let target = availableLetters.first { $0 >= letter } ?? availableLetters.last + if let target { proxy.scrollTo(target, anchor: .top) } + } + ) + .padding(.vertical, 8) + .padding(.trailing, 2) } } .refreshable { @@ -140,50 +147,46 @@ struct ArtistsView: View { struct AlphabetIndexView: View { let letters: [String] + let itemHeight: CGFloat let onSelect: (String) -> Void @State private var activeLetter: String? var body: some View { - GeometryReader { geometry in - let itemHeight = geometry.size.height / CGFloat(letters.count) - - ZStack { - // Touch-responsive column - VStack(spacing: 0) { - ForEach(letters, id: \.self) { letter in - Text(letter) - .font(.system(size: 11, weight: .bold)) - .frame(width: 20, height: itemHeight) - .foregroundStyle(activeLetter == letter ? .white : .accentColor) - .background { - if activeLetter == letter { - Circle() - .fill(Color.accentColor) - .frame(width: 18, height: 18) - } - } + VStack(spacing: 0) { + ForEach(letters, id: \.self) { letter in + Text(letter) + .font(.system(size: min(13, itemHeight * 0.65), weight: .bold)) + .frame(width: 20, height: itemHeight) + .padding(.top, letter == "#" ? 6 : 0) + .foregroundStyle(activeLetter == letter ? .white : .accentColor) + .background { + if activeLetter == letter { + Circle() + .fill(Color.accentColor) + .frame(width: min(18, itemHeight - 2), height: min(18, itemHeight - 2)) + } } - } - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let index = Int(value.location.y / itemHeight) - let clamped = max(0, min(letters.count - 1, index)) - let letter = letters[clamped] - if activeLetter != letter { - activeLetter = letter - onSelect(letter) - UISelectionFeedbackGenerator().selectionChanged() - } - } - .onEnded { _ in - activeLetter = nil - } - ) } } - .frame(width: 20) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let safeHeight = max(1, itemHeight) + let index = Int(value.location.y / safeHeight) + let clamped = max(0, min(letters.count - 1, index)) + let letter = letters[clamped] + if activeLetter != letter { + activeLetter = letter + onSelect(letter) + UISelectionFeedbackGenerator().selectionChanged() + } + } + .onEnded { _ in + activeLetter = nil + } + ) } } @@ -195,7 +198,7 @@ struct ArtistGridItem: View { var body: some View { VStack(spacing: 4) { - CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 128)) { image in + CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 256)) { image in image .resizable() .aspectRatio(contentMode: .fill) @@ -204,11 +207,11 @@ struct ArtistGridItem: View { .fill(Color.gray.opacity(0.2)) .overlay { Image(systemName: "music.mic") - .font(.system(size: 22)) + .font(.system(size: 30)) .foregroundStyle(.secondary) } } - .frame(width: 76, height: 76) + .aspectRatio(1, contentMode: .fit) .clipShape(Circle()) Text(artist.name) diff --git a/Mobile Music Assistant/ViewsLibraryLibraryView.swift b/Mobile Music Assistant/ViewsLibraryLibraryView.swift index 0cfd8b2..99007f0 100644 --- a/Mobile Music Assistant/ViewsLibraryLibraryView.swift +++ b/Mobile Music Assistant/ViewsLibraryLibraryView.swift @@ -17,6 +17,7 @@ enum LibraryTab: String, CaseIterable { struct LibraryView: View { @Environment(MAService.self) private var service @State private var selectedTab: LibraryTab = .artists + @State private var showSearch = false @State private var refreshError: String? @State private var showError = false @@ -75,22 +76,28 @@ struct LibraryView: View { } ToolbarItem(placement: .primaryAction) { - NavigationLink { - SearchView() + Button { + showSearch = true } label: { Label("Search", systemImage: "magnifyingglass") } } } - .navigationDestination(for: MAArtist.self) { ArtistDetailView(artist: $0) } - .navigationDestination(for: MAAlbum.self) { AlbumDetailView(album: $0) } - .navigationDestination(for: MAPlaylist.self) { PlaylistDetailView(playlist: $0) } + .withMANavigation() .alert("Refresh Failed", isPresented: $showError) { Button("OK", role: .cancel) { } } message: { if let refreshError { Text(refreshError) } } } + // Search presented as isolated sheet with its own NavigationStack. + // This prevents the main LibraryView stack from being affected by + // search-internal navigation (artist/album/playlist detail). + .sheet(isPresented: $showSearch) { + NavigationStack { + SearchView() + } + } } // MARK: - Helpers diff --git a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift index a89757a..2c86a0d 100644 --- a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift @@ -17,30 +17,22 @@ struct PlaylistDetailView: View { // 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 { + CachedAsyncImage(url: service.imageProxyURL(path: playlist.imageUrl, provider: playlist.imageProvider, size: 512)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { 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) } } + .frame(width: 250, height: 250) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(radius: 10) // Playlist Info VStack(spacing: 8) { diff --git a/Mobile Music Assistant/ViewsLibraryPlaylistsView.swift b/Mobile Music Assistant/ViewsLibraryPlaylistsView.swift index 8c3c471..bc8ecc4 100644 --- a/Mobile Music Assistant/ViewsLibraryPlaylistsView.swift +++ b/Mobile Music Assistant/ViewsLibraryPlaylistsView.swift @@ -41,16 +41,11 @@ struct PlaylistsView: View { .listStyle(.plain) } } - .navigationDestination(for: MAPlaylist.self) { playlist in - PlaylistDetailView(playlist: playlist) - } .refreshable { await loadPlaylists(refresh: true) } .task { - if playlists.isEmpty { - await loadPlaylists(refresh: false) - } + await loadPlaylists(refresh: !playlists.isEmpty) } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } @@ -80,29 +75,21 @@ struct PlaylistRow: View { 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 { + CachedAsyncImage(url: service.imageProxyURL(path: playlist.imageUrl, provider: playlist.imageProvider, size: 128)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.2)) - .frame(width: 64, height: 64) .overlay { Image(systemName: "music.note.list") .font(.title2) .foregroundStyle(.secondary) } } + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 8)) // Playlist Info VStack(alignment: .leading, spacing: 4) { diff --git a/Mobile Music Assistant/ViewsLibraryRadiosView.swift b/Mobile Music Assistant/ViewsLibraryRadiosView.swift index e56bf1f..837e01a 100644 --- a/Mobile Music Assistant/ViewsLibraryRadiosView.swift +++ b/Mobile Music Assistant/ViewsLibraryRadiosView.swift @@ -18,7 +18,9 @@ struct RadiosView: View { @State private var selectedRadio: MAMediaItem? private var players: [MAPlayer] { - Array(service.playerManager.players.values).sorted { $0.name < $1.name } + Array(service.playerManager.players.values) + .filter { $0.available } + .sorted { $0.name < $1.name } } var body: some View { @@ -56,9 +58,12 @@ struct RadiosView: View { } .sheet(isPresented: $showPlayerPicker) { if let radio = selectedRadio { - PlayerPickerView(players: players) { player in - Task { await playRadio(radio, on: player) } - } + EnhancedPlayerPickerView( + players: players, + onSelect: { player in + Task { await playRadio(radio, on: player) } + } + ) } } } @@ -84,6 +89,7 @@ struct RadiosView: View { showError = true } } + } // MARK: - Radio Row @@ -94,23 +100,18 @@ private struct RadioRow: View { var body: some View { HStack(spacing: 12) { - if let imageUrl = radio.imageUrl { - CachedAsyncImage(url: service.imageProxyURL(path: imageUrl, size: 128)) { image in - image.resizable().aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: 8).fill(Color.gray.opacity(0.2)) - } - .frame(width: 50, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { + CachedAsyncImage(url: service.imageProxyURL(path: radio.imageUrl, provider: radio.imageProvider, size: 128)) { image in + image.resizable().aspectRatio(contentMode: .fill) + } placeholder: { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.2)) - .frame(width: 50, height: 50) .overlay { Image(systemName: "antenna.radiowaves.left.and.right") .foregroundStyle(.secondary) } } + .frame(width: 50, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 8)) Text(radio.name) .font(.body) diff --git a/Mobile Music Assistant/ViewsLibrarySearchView.swift b/Mobile Music Assistant/ViewsLibrarySearchView.swift index 6a548d4..082b8c2 100644 --- a/Mobile Music Assistant/ViewsLibrarySearchView.swift +++ b/Mobile Music Assistant/ViewsLibrarySearchView.swift @@ -21,40 +21,44 @@ struct SearchView: View { @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() + Group { + if searchResults.isEmpty && !isSearching { + if searchText.isEmpty { + ContentUnavailableView( + "Search Library", + systemImage: "magnifyingglass", + description: Text("Find artists, albums, tracks, and playlists") + ) } else { - searchResultsList + 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) + } + .navigationTitle("Search") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } } - .alert("Error", isPresented: $showError) { - Button("OK", role: .cancel) { } - } message: { - if let errorMessage { - Text(errorMessage) - } + } + .withMANavigation() + .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) } } } @@ -64,17 +68,146 @@ struct SearchView: View { @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 + // Group results by media type + let groupedResults = Dictionary(grouping: searchResults) { $0.mediaType ?? .unknown } + + // Artists + if let artists = groupedResults[.artist], !artists.isEmpty { + Section { + ForEach(artists) { item in + NavigationLink(value: convertToArtist(item)) { + SearchResultRow(item: item) + } } + } header: { + Label("Artists", systemImage: "music.mic") + .font(.headline) + .foregroundStyle(.primary) + } + } + + // Albums + if let albums = groupedResults[.album], !albums.isEmpty { + Section { + ForEach(albums) { item in + NavigationLink(value: convertToAlbum(item)) { + SearchResultRow(item: item) + } + } + } header: { + Label("Albums", systemImage: "opticaldisc") + .font(.headline) + .foregroundStyle(.primary) + } + } + + // Tracks + if let tracks = groupedResults[.track], !tracks.isEmpty { + Section { + ForEach(tracks) { item in + SearchResultRow(item: item) + .contentShape(Rectangle()) + .onTapGesture { + print("🎵 Tapped track: \(item.name)") + // TODO: Play track + } + } + } header: { + Label("Tracks", systemImage: "music.note") + .font(.headline) + .foregroundStyle(.primary) + } + } + + // Playlists + if let playlists = groupedResults[.playlist], !playlists.isEmpty { + Section { + ForEach(playlists) { item in + NavigationLink(value: convertToPlaylist(item)) { + SearchResultRow(item: item) + } + } + } header: { + Label("Playlists", systemImage: "music.note.list") + .font(.headline) + .foregroundStyle(.primary) + } + } + + // Radios + if let radios = groupedResults[.radio], !radios.isEmpty { + Section { + ForEach(radios) { item in + SearchResultRow(item: item) + .contentShape(Rectangle()) + .onTapGesture { + print("📻 Tapped radio: \(item.name)") + // TODO: Play radio + } + } + } header: { + Label("Radio Stations", systemImage: "antenna.radiowaves.left.and.right") + .font(.headline) + .foregroundStyle(.primary) + } } } .listStyle(.plain) } + // MARK: - Navigation Helper + + private func convertToAlbum(_ item: MAMediaItem) -> MAAlbum { + print("🔄 Converting to album: \(item.name) (URI: \(item.uri))") + return MAAlbum( + uri: item.uri, + name: item.name, + artists: item.artists, + imageUrl: item.imageUrl, + imageProvider: item.imageProvider, + year: nil + ) + } + + private func convertToPlaylist(_ item: MAMediaItem) -> MAPlaylist { + return MAPlaylist( + uri: item.uri, + name: item.name, + imageUrl: item.imageUrl + ) + } + + private func convertToArtist(_ item: MAMediaItem) -> MAArtist { + print("🔄 Converting to artist: \(item.name) (URI: \(item.uri))") + + // If the item itself is an artist, use its data + if item.mediaType == .artist { + return MAArtist( + uri: item.uri, + name: item.name, + imageUrl: item.imageUrl, + imageProvider: item.imageProvider, + sortName: nil, + musicbrainzId: nil + ) + } + + // Otherwise try to use the first artist from the artists array + if let firstArtist = item.artists?.first { + return firstArtist + } + + // Fallback + return MAArtist( + uri: item.uri, + name: item.name, + imageUrl: item.imageUrl, + imageProvider: item.imageProvider, + sortName: nil, + musicbrainzId: nil + ) + } + // MARK: - Search private func performSearch(query: String) { @@ -97,21 +230,31 @@ struct SearchView: View { } private func executeSearch(query: String) async { + print("🔍 Starting search for: '\(query)'") isSearching = true errorMessage = nil do { let results = try await service.libraryManager.search(query: query) + print("✅ Search returned \(results.count) results") + + // Debug: Print first result + if let first = results.first { + print("📦 First result: \(first.name) (type: \(first.mediaType?.rawValue ?? "nil"))") + } await MainActor.run { searchResults = results isSearching = false + print("✅ UI updated with \(searchResults.count) results") } } catch { await MainActor.run { errorMessage = error.localizedDescription showError = true + searchResults = [] isSearching = false + print("❌ Search Error: \(error)") } } } @@ -126,28 +269,20 @@ struct SearchResultRow: View { 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 { + CachedAsyncImage(url: service.imageProxyURL(path: item.imageUrl, provider: item.imageProvider, size: 128)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { thumbnailShape .fill(Color.gray.opacity(0.2)) - .frame(width: 60, height: 60) .overlay { Image(systemName: mediaTypeIcon) .foregroundStyle(.secondary) } } + .frame(width: 60, height: 60) + .clipShape(thumbnailShape) // Item Info VStack(alignment: .leading, spacing: 4) { @@ -167,7 +302,7 @@ struct SearchResultRow: View { .lineLimit(1) } - Label(item.mediaType.rawValue.capitalized, systemImage: mediaTypeIcon) + Label((item.mediaType?.rawValue ?? "unknown").capitalized, systemImage: mediaTypeIcon) .font(.caption2) .foregroundStyle(.tertiary) } @@ -178,12 +313,10 @@ struct SearchResultRow: View { } private var thumbnailShape: some Shape { - switch item.mediaType { - case .artist: + if item.mediaType == .artist { return AnyShape(Circle()) - default: - return AnyShape(RoundedRectangle(cornerRadius: 8)) } + return AnyShape(RoundedRectangle(cornerRadius: 8)) } private var mediaTypeIcon: String { @@ -193,6 +326,7 @@ struct SearchResultRow: View { case .artist: return "music.mic" case .playlist: return "music.note.list" case .radio: return "antenna.radiowaves.left.and.right" + default: return "questionmark" } } } diff --git a/Mobile Music Assistant/ViewsLoginView.swift b/Mobile Music Assistant/ViewsLoginView.swift index 253d786..00125d1 100644 --- a/Mobile Music Assistant/ViewsLoginView.swift +++ b/Mobile Music Assistant/ViewsLoginView.swift @@ -107,6 +107,7 @@ struct LoginView: View { } } } + .applyTheme() } // MARK: - Computed Properties diff --git a/Mobile Music Assistant/ViewsMainTabView.swift b/Mobile Music Assistant/ViewsMainTabView.swift index dcaffc7..99e7f4b 100644 --- a/Mobile Music Assistant/ViewsMainTabView.swift +++ b/Mobile Music Assistant/ViewsMainTabView.swift @@ -9,27 +9,27 @@ 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("Players", systemImage: "speaker.wave.2.fill") { + PlayerListView() + } + Tab("Settings", systemImage: "gear") { SettingsView() } } .task { - // Start listening to player events when main view appears + // Start listening to player events and load players when main view appears service.playerManager.startListening() + try? await service.playerManager.loadPlayers() } .onDisappear { - // Stop listening when view disappears service.playerManager.stopListening() } } @@ -41,11 +41,31 @@ 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 } + @State private var nowPlayingPlayer: MAPlayer? + + private var allPlayers: [MAPlayer] { + Array(service.playerManager.players.values) + .filter { $0.available } + .sorted { $0.name < $1.name } } - + + /// IDs of all players that are sync members (not the leader) + private var syncedMemberIds: Set { + Set(allPlayers.flatMap { $0.groupChilds }) + } + + /// Players that are sync group leaders (shown as group cards at the top) + private var groupLeaders: [MAPlayer] { + allPlayers.filter { $0.isGroupLeader } + } + + /// Players that are neither a group leader nor a member of any group + private var soloPlayers: [MAPlayer] { + allPlayers.filter { !$0.isGroupLeader && !syncedMemberIds.contains($0.playerId) } + } + + private var hasContent: Bool { !allPlayers.isEmpty } + var body: some View { NavigationStack { Group { @@ -57,20 +77,39 @@ struct PlayerListView: View { systemImage: "exclamationmark.triangle", description: Text(errorMessage) ) - } else if players.isEmpty { + } else if !hasContent { 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) + ScrollView { + // VStack (not Lazy) ensures all drop targets are always rendered + VStack(spacing: 12) { + // Groups shown at the top + ForEach(groupLeaders) { leader in + let memberNames = leader.groupChilds + .compactMap { service.playerManager.players[$0]?.name } + PlayerGroupRow( + leader: leader, + memberNames: memberNames, + onTap: { nowPlayingPlayer = leader }, + onDissolve: { + Task { try? await service.playerManager.unsyncPlayer(playerId: leader.playerId) } + } + ) + } + + // Solo players — drag handle initiates drag, card accepts drops + ForEach(soloPlayers) { player in + PlayerRow(player: player) { + nowPlayingPlayer = player + } + } } - } - .navigationDestination(for: String.self) { playerId in - PlayerView(playerId: playerId) + .padding(.horizontal, 16) + .padding(.vertical, 8) } } } @@ -78,139 +117,317 @@ struct PlayerListView: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button { - Task { - await loadPlayers() - } + Task { await loadPlayers() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } } } + .withMANavigation() .task { await loadPlayers() } + .sheet(item: $nowPlayingPlayer) { selectedPlayer in + PlayerNowPlayingView(playerId: selectedPlayer.playerId) + .environment(service) + } } } - + 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 } } +// MARK: - Player Group Row + +struct PlayerGroupRow: View { + @Environment(MAService.self) private var service + let leader: MAPlayer + let memberNames: [String] + let onTap: () -> Void + let onDissolve: () -> Void + + private var currentItem: MAQueueItem? { + service.playerManager.playerQueues[leader.playerId]?.currentItem + } + private var mediaItem: MAMediaItem? { currentItem?.mediaItem } + + private var groupName: String { + ([leader.name] + memberNames).joined(separator: " + ") + } + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "speaker.2.fill") + .font(.caption) + .foregroundStyle(.blue) + if leader.state == .playing { + Image(systemName: "waveform") + .font(.caption) + .foregroundStyle(.green) + } + Text(groupName) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + } + + if let item = currentItem { + Text(item.name) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + if let artists = mediaItem?.artists, !artists.isEmpty { + Text(artists.map { $0.name }.joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } else { + Text(leader.state == .off ? "Powered Off" : "No Track Playing") + .font(.subheadline) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + + Spacer() + + // Play/pause + Button { + Task { + if leader.state == .playing { + try? await service.playerManager.pause(playerId: leader.playerId) + } else { + try? await service.playerManager.play(playerId: leader.playerId) + } + } + } label: { + Image(systemName: leader.state == .playing ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(leader.state == .playing ? .green : .secondary) + .symbolEffect(.bounce, value: leader.state == .playing) + } + .buttonStyle(.plain) + + // Dissolve group button + Button(action: onDissolve) { + Image(systemName: "xmark.circle") + .font(.system(size: 22)) + .foregroundStyle(.red.opacity(0.7)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background { + ZStack { + CachedAsyncImage(url: service.imageProxyURL( + path: mediaItem?.imageUrl, + provider: mediaItem?.imageProvider, + size: 256 + )) { image in + image.resizable().aspectRatio(contentMode: .fill) + } placeholder: { + Color.clear + } + .blur(radius: 20) + .scaleEffect(1.1) + .clipped() + Rectangle().fill(.ultraThinMaterial) + } + } + .clipShape(RoundedRectangle(cornerRadius: 16)) + .contentShape(RoundedRectangle(cornerRadius: 16)) + .onTapGesture { onTap() } + } +} + +// MARK: - Player Row + struct PlayerRow: View { @Environment(MAService.self) private var service let player: MAPlayer - + let onTap: () -> Void + @State private var isDropTarget = false + + private var currentItem: MAQueueItem? { + service.playerManager.playerQueues[player.playerId]?.currentItem + } + + private var mediaItem: MAMediaItem? { + currentItem?.mediaItem + } + 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 + // Drag handle — long-press this icon then drag onto another player to group them. + // Keeping it on a small dedicated view avoids conflicts with the ScrollView gesture. + Image(systemName: "line.3.horizontal") + .font(.body) + .foregroundStyle(.secondary) + .frame(width: 24, height: 44) + .contentShape(Rectangle()) + .draggable(player.playerId) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + if player.state == .playing { + Image(systemName: "waveform") + .font(.caption) + .foregroundStyle(.green) + } + Text(player.name) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + } + + if let item = currentItem { + Text(item.name) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let artists = mediaItem?.artists, !artists.isEmpty { + Text(artists.map { $0.name }.joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } else { + Text(player.state == .off ? "Powered Off" : "No Track Playing") + .font(.subheadline) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + + Spacer() + + Button { + Task { + if player.state == .playing { + try? await service.playerManager.pause(playerId: player.playerId) + } else { + try? await service.playerManager.play(playerId: player.playerId) + } + } + } label: { + Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(player.state == .playing ? .green : .secondary) + .symbolEffect(.bounce, value: player.state == .playing) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background { + ZStack { + CachedAsyncImage(url: service.imageProxyURL( + path: mediaItem?.imageUrl, + provider: mediaItem?.imageProvider, + size: 256 + )) { 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) + Color.clear } + .blur(radius: 20) + .scaleEffect(1.1) + .clipped() + Rectangle().fill(.ultraThinMaterial) } } - .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" + .clipShape(RoundedRectangle(cornerRadius: 16)) + .contentShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + if isDropTarget { + RoundedRectangle(cornerRadius: 16) + .stroke(Color.accentColor, lineWidth: 2) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.accentColor.opacity(0.08)) + ) + } } - } - - private var stateColor: Color { - switch player.state { - case .playing: return .green - case .paused: return .orange - case .idle: return .gray - case .off: return .red + .onTapGesture { onTap() } + // The full card is the drop target — drop a player from another card's handle here + .dropDestination(for: String.self) { items, _ in + guard let draggedId = items.first, draggedId != player.playerId else { return false } + Task { try? await service.playerManager.syncPlayer(playerId: draggedId, targetPlayerId: player.playerId) } + return true + } isTargeted: { targeted in + isDropTarget = targeted } } } -// Removed - Now using dedicated PlayerView.swift file - // Removed - Now using dedicated LibraryView.swift file struct SettingsView: View { @Environment(MAService.self) private var service + @Environment(\.themeManager) private var themeManager var body: some View { NavigationStack { Form { + // Appearance Section + Section { + ForEach(AppColorScheme.allCases) { scheme in + Button { + withAnimation(.easeInOut(duration: 0.3)) { + themeManager.colorScheme = scheme + } + } label: { + HStack { + Image(systemName: scheme.icon) + .font(.title2) + .foregroundStyle(themeManager.colorScheme == scheme ? .blue : .secondary) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(scheme.displayName) + .font(.body) + .foregroundStyle(.primary) + + Text(scheme.description) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if themeManager.colorScheme == scheme { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.blue) + .font(.title3) + } + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } + } header: { + Text("Appearance") + } footer: { + Text("Choose how the app looks. System follows your device settings.") + } + + // Connection Section Section { if let serverURL = service.authManager.serverURL { LabeledContent("Server", value: serverURL.absoluteString) @@ -224,8 +441,11 @@ struct SettingsView: View { Text(service.isConnected ? "Connected" : "Disconnected") } } + } header: { + Text("Connection") } + // Actions Section Section { Button(role: .destructive) { service.disconnect() diff --git a/Mobile Music Assistant/ViewsRootView.swift b/Mobile Music Assistant/ViewsRootView.swift index 72d8212..ea51540 100644 --- a/Mobile Music Assistant/ViewsRootView.swift +++ b/Mobile Music Assistant/ViewsRootView.swift @@ -29,6 +29,7 @@ struct RootView: View { LoginView() } } + .applyTheme() .task { await initializeConnection() } diff --git a/ViewsPlayerNowPlayingView.swift b/ViewsPlayerNowPlayingView.swift index 4cdf771..587d974 100644 --- a/ViewsPlayerNowPlayingView.swift +++ b/ViewsPlayerNowPlayingView.swift @@ -12,82 +12,65 @@ struct PlayerNowPlayingView: View { @Environment(\.dismiss) private var dismiss let playerId: String + @State private var localVolume: Double = 0 + @State private var isVolumeEditing = false + @State private var isMuted = false + @State private var preMuteVolume: Double = 50 + // Auto-tracks live updates via @Observable private var player: MAPlayer? { service.playerManager.players[playerId] } + private var currentItem: MAQueueItem? { + service.playerManager.playerQueues[playerId]?.currentItem + } + private var mediaItem: MAMediaItem? { - player?.currentItem?.mediaItem + currentItem?.mediaItem } var body: some View { - ZStack { - // Blurred artwork background - CachedAsyncImage(url: service.imageProxyURL( - path: mediaItem?.imageUrl, - provider: mediaItem?.imageProvider, - size: 64 - )) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Color.clear - } - .ignoresSafeArea() - .blur(radius: 80) - .scaleEffect(1.4) - .opacity(0.5) + // ScrollView is the root — fills the sheet top-to-bottom, no centering + ScrollView { + VStack(spacing: 16) { + // Drag indicator + Capsule() + .fill(.secondary.opacity(0.4)) + .frame(width: 36, height: 4) + .padding(.top, 8) - Rectangle() - .fill(.ultraThinMaterial) - .ignoresSafeArea() + // Header: dismiss + player name + HStack { + Button { dismiss() } label: { + Image(systemName: "chevron.down") + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } - // Content - VStack(spacing: 0) { - // Drag indicator - Capsule() - .fill(.secondary.opacity(0.4)) - .frame(width: 36, height: 4) - .padding(.top, 12) - .padding(.bottom, 8) + Spacer() - // Player name - HStack { - Button { dismiss() } label: { - Image(systemName: "chevron.down") - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(.primary) + VStack(spacing: 2) { + Text("Now Playing") + .font(.caption) + .foregroundStyle(.secondary) + Text(player?.name ?? "") + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + } + + Spacer() + + Color.clear .frame(width: 44, height: 44) - .contentShape(Rectangle()) } + .padding(.horizontal, 20) - Spacer() - - VStack(spacing: 2) { - Text("Now Playing") - .font(.caption) - .foregroundStyle(.secondary) - Text(player?.name ?? "") - .font(.subheadline) - .fontWeight(.semibold) - .lineLimit(1) - } - - Spacer() - - // Balance chevron button - Color.clear - .frame(width: 44, height: 44) - } - .padding(.horizontal, 20) - .padding(.bottom, 8) - - // Album art - GeometryReader { geo in - let size = min(geo.size.width - 64, geo.size.height) + // Album art CachedAsyncImage(url: service.imageProxyURL( path: mediaItem?.imageUrl, provider: mediaItem?.imageProvider, @@ -95,123 +78,177 @@ struct PlayerNowPlayingView: View { )) { image in image .resizable() - .aspectRatio(1, contentMode: .fill) + .scaledToFill() } placeholder: { - RoundedRectangle(cornerRadius: 16) - .fill(Color.gray.opacity(0.2)) + Color.gray.opacity(0.2) .overlay { Image(systemName: "music.note") .font(.system(size: 56)) .foregroundStyle(.secondary) } } - .frame(width: size, height: size) + .frame(width: 260, height: 260) .clipShape(RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.35), radius: 24, y: 12) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .padding(.horizontal, 32) - .padding(.vertical, 24) - // Track info - VStack(spacing: 6) { - Text(player?.currentItem?.name ?? "–") - .font(.title2) - .fontWeight(.bold) - .lineLimit(2) - .multilineTextAlignment(.center) + // Track info + VStack(spacing: 6) { + Text(currentItem?.name ?? "–") + .font(.title2) + .fontWeight(.bold) + .lineLimit(2) + .multilineTextAlignment(.center) - if let artists = mediaItem?.artists, !artists.isEmpty { - Text(artists.map { $0.name }.joined(separator: ", ")) - .font(.body) - .foregroundStyle(.secondary) - .lineLimit(1) - } else if let album = mediaItem?.album { - Text(album.name) - .font(.body) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .padding(.horizontal, 32) - - Spacer(minLength: 24) - - // Transport controls - if let player { - HStack(spacing: 48) { - Button { - Task { try? await service.playerManager.previousTrack(playerId: playerId) } - } label: { - Image(systemName: "backward.fill") - .font(.system(size: 30)) - .foregroundStyle(.primary) + if let artists = mediaItem?.artists, !artists.isEmpty { + Text(artists.map { $0.name }.joined(separator: ", ")) + .font(.body) + .foregroundStyle(.secondary) + .lineLimit(1) + } else if let album = mediaItem?.album { + Text(album.name) + .font(.body) + .foregroundStyle(.secondary) + .lineLimit(1) } - - 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: 72)) - .foregroundStyle(.primary) - .symbolEffect(.bounce, value: player.state == .playing) - } - - Button { - Task { try? await service.playerManager.nextTrack(playerId: playerId) } - } label: { - Image(systemName: "forward.fill") - .font(.system(size: 30)) - .foregroundStyle(.primary) - } - } - .buttonStyle(.plain) - } - - Spacer(minLength: 24) - - // Volume - if let volume = player?.volume { - HStack(spacing: 10) { - Image(systemName: "speaker.fill") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 20) - - Slider( - value: Binding( - get: { Double(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") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 20) } .padding(.horizontal, 32) - } - Spacer(minLength: 32) + // Transport controls + if let player { + HStack(spacing: 48) { + Button { + Task { try? await service.playerManager.previousTrack(playerId: playerId) } + } label: { + Image(systemName: "backward.fill") + .font(.system(size: 30)) + .foregroundStyle(.primary) + } + + 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: 72)) + .foregroundStyle(.primary) + .symbolEffect(.bounce, value: player.state == .playing) + } + + Button { + Task { try? await service.playerManager.nextTrack(playerId: playerId) } + } label: { + Image(systemName: "forward.fill") + .font(.system(size: 30)) + .foregroundStyle(.primary) + } + } + .buttonStyle(.plain) + } + + // Volume control + HStack(spacing: 10) { + // Mute toggle + Button { handleMute() } label: { + Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.slash") + .font(.system(size: 15)) + .foregroundStyle(isMuted ? .primary : .secondary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + + // Volume down –5 + Button { adjustVolume(by: -5) } label: { + Image(systemName: "speaker.fill") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + Slider(value: $localVolume, in: 0...100, step: 1) { editing in + isVolumeEditing = editing + if !editing { + Task { + try? await service.playerManager.setVolume( + playerId: playerId, + level: Int(localVolume) + ) + } + } + } + + // Volume up +5 + Button { adjustVolume(by: 5) } label: { + Image(systemName: "speaker.wave.3.fill") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 32) + .padding(.bottom, 32) + } + } + .scrollDisabled(true) + .background { + ZStack { + CachedAsyncImage(url: service.imageProxyURL( + path: mediaItem?.imageUrl, + provider: mediaItem?.imageProvider, + size: 64 + )) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color.clear + } + .blur(radius: 80) + .scaleEffect(1.4) + .opacity(0.5) + + Rectangle() + .fill(.ultraThinMaterial) + } + .ignoresSafeArea() + } + .onChange(of: player?.volume) { _, newVolume in + if !isVolumeEditing, let v = newVolume { + localVolume = Double(v) + if v > 0 { isMuted = false } } } + .onAppear { + localVolume = Double(player?.volume ?? 50) + } .presentationDetents([.large]) - .presentationDragIndicator(.hidden) // using custom indicator + .presentationDragIndicator(.hidden) + } + + // MARK: - Volume Helpers + + private func adjustVolume(by delta: Int) { + let newVolume = max(0, min(100, Int(localVolume) + delta)) + localVolume = Double(newVolume) + if isMuted && delta > 0 { isMuted = false } + Task { try? await service.playerManager.setVolume(playerId: playerId, level: newVolume) } + } + + private func handleMute() { + if isMuted { + let restore = preMuteVolume > 0 ? preMuteVolume : 50 + localVolume = restore + isMuted = false + Task { try? await service.playerManager.setVolume(playerId: playerId, level: Int(restore)) } + } else { + preMuteVolume = localVolume + localVolume = 0 + isMuted = true + Task { try? await service.playerManager.setVolume(playerId: playerId, level: 0) } + } } }