Version one on the App Store

This commit is contained in:
2026-04-05 19:44:30 +02:00
parent c780be089d
commit 3ebf1763ed
26 changed files with 2088 additions and 842 deletions
@@ -6,7 +6,12 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerNowPlayingView.swift; sourceTree = "<group>"; };
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -34,6 +39,7 @@
children = ( children = (
26ED92632F759EEA0025419D /* Mobile Music Assistant */, 26ED92632F759EEA0025419D /* Mobile Music Assistant */,
26ED92622F759EEA0025419D /* Products */, 26ED92622F759EEA0025419D /* Products */,
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -119,6 +125,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -269,12 +276,16 @@
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; 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_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
}; };
name = Debug; name = Debug;
}; };
@@ -301,12 +312,16 @@
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; 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_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
}; };
name = Release; name = Release;
}; };
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "26ED92602F759EEA0025419D"
BuildableName = "Mobile Music Assistant.app"
BlueprintName = "Mobile Music Assistant"
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "26ED92602F759EEA0025419D"
BuildableName = "Mobile Music Assistant.app"
BlueprintName = "Mobile Music Assistant"
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "26ED92602F759EEA0025419D"
BuildableName = "Mobile Music Assistant.app"
BlueprintName = "Mobile Music Assistant"
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -10,5 +10,13 @@
<integer>0</integer> <integer>0</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>26ED92602F759EEA0025419D</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict> </dict>
</plist> </plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -12,7 +12,6 @@ enum MANavigationDestination: Hashable {
case artist(MAArtist) case artist(MAArtist)
case album(MAAlbum) case album(MAAlbum)
case playlist(MAPlaylist) case playlist(MAPlaylist)
case player(String) // playerId
} }
/// ViewModifier to apply all navigation destinations consistently /// ViewModifier to apply all navigation destinations consistently
@@ -36,8 +35,6 @@ struct MANavigationDestinations: ViewModifier {
AlbumDetailView(album: album) AlbumDetailView(album: album)
case .playlist(let playlist): case .playlist(let playlist):
PlaylistDetailView(playlist: playlist) PlaylistDetailView(playlist: playlist)
case .player(let playerId):
PlayerView(playerId: playerId)
} }
} }
} }
@@ -10,11 +10,13 @@ import SwiftUI
@main @main
struct Mobile_Music_AssistantApp: App { struct Mobile_Music_AssistantApp: App {
@State private var service = MAService() @State private var service = MAService()
@State private var themeManager = MAThemeManager()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(service) .environment(service)
.environment(themeManager)
} }
} }
} }
+281 -27
View File
@@ -14,11 +14,17 @@ struct MAPlayer: Codable, Identifiable, Hashable {
let name: String let name: String
let state: PlayerState let state: PlayerState
let currentItem: MAQueueItem? let currentItem: MAQueueItem?
let volume: Int let volume: Int?
let powered: Bool let powered: Bool
let available: 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 id: String { playerId }
var isGroupLeader: Bool { !groupChilds.isEmpty }
var isSyncMember: Bool { !syncLeader.isEmpty }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case playerId = "player_id" case playerId = "player_id"
@@ -28,6 +34,34 @@ struct MAPlayer: Codable, Identifiable, Hashable {
case volume = "volume_level" case volume = "volume_level"
case powered case powered
case available 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)) ?? []
} }
} }
@@ -56,6 +90,23 @@ struct MAQueueItem: Codable, Identifiable, Hashable {
case duration case duration
case streamDetails = "stream_details" 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 { 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 // MARK: - Media Models
struct MAMediaItem: Codable, Identifiable, Hashable { struct MAMediaItem: Codable, Identifiable, Hashable {
let uri: String let uri: String
let name: String let name: String
let mediaType: MediaType let mediaType: MediaType?
let artists: [MAArtist]? let artists: [MAArtist]?
let album: MAAlbum? let album: MAAlbum?
let imageUrl: String? let metadata: MediaItemMetadata?
let duration: Int? let duration: Int?
var id: String { uri } var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case uri case uri, name, duration, artists, album, metadata
case name
case mediaType = "media_type" case mediaType = "media_type"
case artists case image // Direct image field from search results
case album }
case imageUrl = "image"
case duration 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 artist
case playlist case playlist
case radio case radio
case audiobook
case podcast
case podcastEpisode = "podcast_episode"
case unknown
} }
struct MAArtist: Codable, Identifiable, Hashable { struct MAArtist: Codable, Identifiable, Hashable {
let uri: String let uri: String
let name: String let name: String
let imageUrl: String? let metadata: MediaItemMetadata?
let sortName: String? let sortName: String?
let musicbrainzId: String? let musicbrainzId: String?
var id: String { uri } var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case uri case uri, name, metadata
case name
case imageUrl = "image"
case sortName = "sort_name" case sortName = "sort_name"
case musicbrainzId = "musicbrainz_id" 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 uri: String
let name: String let name: String
let artists: [MAArtist]? let artists: [MAArtist]?
let imageUrl: String? let metadata: MediaItemMetadata?
let year: Int? let year: Int?
var id: String { uri } var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case uri case uri, name, artists, metadata, year
case name case image // Direct image field
case artists }
case imageUrl = "image"
case year 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 uri: String
let name: String let name: String
let owner: String? let owner: String?
let imageUrl: String? let metadata: MediaItemMetadata?
let isEditable: Bool let isEditable: Bool
var id: String { uri } var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case uri case uri, name, owner, metadata
case name
case owner
case imageUrl = "image"
case isEditable = "is_editable" 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 // MARK: - WebSocket Protocol Models
@@ -185,14 +439,14 @@ struct MACommand: Encodable {
struct MAResponse: Decodable { struct MAResponse: Decodable {
let messageId: String? let messageId: String?
let result: AnyCodable? let result: AnyCodable?
let errorCode: String? let errorCode: Int?
let errorMessage: String? let details: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case messageId = "message_id" case messageId = "message_id"
case result case result
case errorCode = "error_code" case errorCode = "error_code"
case errorMessage = "error" case details
} }
} }
@@ -41,6 +41,9 @@ final class MALibraryManager {
// MARK: - Disk Cache // MARK: - Disk Cache
/// Increment this whenever the model format changes to invalidate stale caches.
private static let cacheVersion = 2
private let cacheDirectory: URL = { private let cacheDirectory: URL = {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let dir = caches.appendingPathComponent("MMLibrary", isDirectory: true) let dir = caches.appendingPathComponent("MMLibrary", isDirectory: true)
@@ -52,9 +55,24 @@ final class MALibraryManager {
init(service: MAService?) { init(service: MAService?) {
self.service = service self.service = service
migrateIfNeeded()
loadFromDisk() 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) { func setService(_ service: MAService) {
self.service = service self.service = service
} }
@@ -110,10 +128,10 @@ final class MALibraryManager {
guard !isLoadingArtists else { return } guard !isLoadingArtists else { return }
guard let service else { throw MAWebSocketClient.ClientError.notConnected } 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 { if refresh {
artistsOffset = 0
hasMoreArtists = true hasMoreArtists = true
artists = []
} }
guard hasMoreArtists else { return } guard hasMoreArtists else { return }
@@ -121,20 +139,25 @@ final class MALibraryManager {
isLoadingArtists = true isLoadingArtists = true
defer { isLoadingArtists = false } 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 { // DEBUG: log first artist's image state so we can trace artwork loading
artists = newArtists if let a = newArtists.first {
} else { logger.debug("DEBUG Artist[0] name=\(a.name) metadata=\(String(describing: a.metadata)) imageUrl=\(a.imageUrl ?? "nil") imageProvider=\(a.imageProvider ?? "nil")")
artists.append(contentsOf: newArtists)
} }
// Replace or append atomically no intermediate empty state
if refresh {
artists = newArtists
artistsOffset = newArtists.count
} else {
artists.append(contentsOf: newArtists)
artistsOffset += newArtists.count artistsOffset += newArtists.count
}
hasMoreArtists = newArtists.count >= pageSize hasMoreArtists = newArtists.count >= pageSize
// Persist to disk after a full load or first page of refresh
if refresh || artistsOffset <= pageSize { if refresh || artistsOffset <= pageSize {
save(artists, "artists.json") save(artists, "artists.json")
lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh") lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh")
@@ -159,10 +182,9 @@ final class MALibraryManager {
guard !isLoadingAlbums else { return } guard !isLoadingAlbums else { return }
guard let service else { throw MAWebSocketClient.ClientError.notConnected } guard let service else { throw MAWebSocketClient.ClientError.notConnected }
let fetchOffset = refresh ? 0 : albumsOffset
if refresh { if refresh {
albumsOffset = 0
hasMoreAlbums = true hasMoreAlbums = true
albums = []
} }
guard hasMoreAlbums else { return } guard hasMoreAlbums else { return }
@@ -170,17 +192,17 @@ final class MALibraryManager {
isLoadingAlbums = true isLoadingAlbums = true
defer { isLoadingAlbums = false } 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 { if refresh {
albums = newAlbums albums = newAlbums
albumsOffset = newAlbums.count
} else { } else {
albums.append(contentsOf: newAlbums) albums.append(contentsOf: newAlbums)
}
albumsOffset += newAlbums.count albumsOffset += newAlbums.count
}
hasMoreAlbums = newAlbums.count >= pageSize hasMoreAlbums = newAlbums.count >= pageSize
if refresh || albumsOffset <= pageSize { if refresh || albumsOffset <= pageSize {
@@ -16,6 +16,7 @@ final class MAPlayerManager {
// MARK: - Properties // MARK: - Properties
private(set) var players: [String: MAPlayer] = [:] private(set) var players: [String: MAPlayer] = [:]
private(set) var playerQueues: [String: MAPlayerQueue] = [:]
private(set) var queues: [String: [MAQueueItem]] = [:] private(set) var queues: [String: [MAQueueItem]] = [:]
private weak var service: MAService? private weak var service: MAService?
@@ -82,31 +83,38 @@ final class MAPlayerManager {
let player = try data.decode(as: MAPlayer.self) let player = try data.decode(as: MAPlayer.self)
await MainActor.run { await MainActor.run {
players[player.playerId] = player 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 { } 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 { private func handleQueueUpdated(_ event: MAEvent) async {
guard let data = event.data, guard let data = event.data else { return }
let dict = data.value as? [String: Any],
let queueId = dict["queue_id"] as? String else { // 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 return
} }
// Reload queue for this player // Fallback: extract queue_id and fetch from API
guard let service else { return } guard let dict = data.value as? [String: Any],
let queueId = dict["queue_id"] as? String,
let service else { return }
do { do {
let items = try await service.getQueue(playerId: queueId) let queue = try await service.getPlayerQueue(playerId: queueId)
await MainActor.run { await MainActor.run {
queues[queueId] = items playerQueues[queueId] = queue
logger.debug("Updated queue for player \(queueId): \(items.count) items") logger.debug("Fetched queue state for player \(queueId), current: \(queue.currentItem?.name ?? "nil")")
} }
} catch { } catch {
logger.error("Failed to reload queue: \(error.localizedDescription)") logger.error("Failed to reload queue state: \(error.localizedDescription)")
} }
} }
@@ -117,7 +125,7 @@ final class MAPlayerManager {
// MARK: - Data Loading // MARK: - Data Loading
/// Load all players /// Load all players and their queue states
func loadPlayers() async throws { func loadPlayers() async throws {
guard let service else { guard let service else {
throw MAWebSocketClient.ClientError.notConnected throw MAWebSocketClient.ClientError.notConnected
@@ -129,6 +137,28 @@ final class MAPlayerManager {
await MainActor.run { await MainActor.run {
players = Dictionary(uniqueKeysWithValues: playerList.map { ($0.playerId, $0) }) 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 /// Load queue for specific player
@@ -189,6 +219,16 @@ final class MAPlayerManager {
try await service.setVolume(playerId: playerId, level: level) 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 { func playMedia(playerId: String, uri: String) async throws {
guard let service else { guard let service else {
throw MAWebSocketClient.ClientError.notConnected throw MAWebSocketClient.ClientError.notConnected
+207 -88
View File
@@ -22,6 +22,10 @@ final class MAService {
private(set) var isConnected = false 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 // MARK: - Initialization
init() { init() {
@@ -74,7 +78,7 @@ final class MAService {
func getPlayers() async throws -> [MAPlayer] { func getPlayers() async throws -> [MAPlayer] {
logger.debug("Fetching players") logger.debug("Fetching players")
return try await webSocketClient.sendCommand( return try await webSocketClient.sendCommand(
"players", "players/all",
resultType: [MAPlayer].self 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) /// Set volume (0-100)
func setVolume(playerId: String, level: Int) async throws { func setVolume(playerId: String, level: Int) async throws {
let clampedLevel = max(0, min(100, level)) let clampedLevel = max(0, min(100, level))
@@ -139,7 +161,17 @@ final class MAService {
// MARK: - Queue // 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] { func getQueue(playerId: String) async throws -> [MAQueueItem] {
logger.debug("Fetching queue for player \(playerId)") logger.debug("Fetching queue for player \(playerId)")
return try await webSocketClient.sendCommand( return try await webSocketClient.sendCommand(
@@ -153,7 +185,7 @@ final class MAService {
func playMedia(playerId: String, uri: String) async throws { func playMedia(playerId: String, uri: String) async throws {
logger.debug("Playing media \(uri) on player \(playerId)") logger.debug("Playing media \(uri) on player \(playerId)")
_ = try await webSocketClient.sendCommand( _ = try await webSocketClient.sendCommand(
"player_queues/cmd/play_media", "player_queues/play_media",
args: [ args: [
"queue_id": playerId, "queue_id": playerId,
"media": [uri] "media": [uri]
@@ -165,7 +197,7 @@ final class MAService {
func playIndex(playerId: String, index: Int) async throws { func playIndex(playerId: String, index: Int) async throws {
logger.debug("Playing index \(index) on player \(playerId)") logger.debug("Playing index \(index) on player \(playerId)")
_ = try await webSocketClient.sendCommand( _ = try await webSocketClient.sendCommand(
"player_queues/cmd/play_index", "player_queues/play_index",
args: [ args: [
"queue_id": playerId, "queue_id": playerId,
"index": index "index": index
@@ -177,7 +209,7 @@ final class MAService {
func moveQueueItem(playerId: String, fromIndex: Int, toIndex: Int) async throws { func moveQueueItem(playerId: String, fromIndex: Int, toIndex: Int) async throws {
logger.debug("Moving queue item from \(fromIndex) to \(toIndex)") logger.debug("Moving queue item from \(fromIndex) to \(toIndex)")
_ = try await webSocketClient.sendCommand( _ = try await webSocketClient.sendCommand(
"player_queues/cmd/move_item", "player_queues/move_item",
args: [ args: [
"queue_id": playerId, "queue_id": playerId,
"queue_item_id": fromIndex, "queue_item_id": fromIndex,
@@ -192,7 +224,7 @@ final class MAService {
func getArtists(limit: Int = 50, offset: Int = 0) async throws -> [MAArtist] { func getArtists(limit: Int = 50, offset: Int = 0) async throws -> [MAArtist] {
logger.debug("Fetching artists (limit: \(limit), offset: \(offset))") logger.debug("Fetching artists (limit: \(limit), offset: \(offset))")
return try await webSocketClient.sendCommand( return try await webSocketClient.sendCommand(
"music/artists", "music/artists/library_items",
args: [ args: [
"limit": limit, "limit": limit,
"offset": offset "offset": offset
@@ -205,7 +237,7 @@ final class MAService {
func getAlbums(limit: Int = 50, offset: Int = 0) async throws -> [MAAlbum] { func getAlbums(limit: Int = 50, offset: Int = 0) async throws -> [MAAlbum] {
logger.debug("Fetching albums (limit: \(limit), offset: \(offset))") logger.debug("Fetching albums (limit: \(limit), offset: \(offset))")
return try await webSocketClient.sendCommand( return try await webSocketClient.sendCommand(
"music/albums", "music/albums/library_items",
args: [ args: [
"limit": limit, "limit": limit,
"offset": offset "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 /// Get playlists
func getPlaylists() async throws -> [MAPlaylist] { func getPlaylists() async throws -> [MAPlaylist] {
logger.debug("Fetching playlists") logger.debug("Fetching playlists")
return try await webSocketClient.sendCommand( return try await webSocketClient.sendCommand(
"music/playlists", "music/playlists/library_items",
resultType: [MAPlaylist].self 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 /// Get album tracks
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] { func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
logger.debug("Fetching tracks for album \(albumUri)") 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( return try await webSocketClient.sendCommand(
"music/album_tracks", "music/albums/album_tracks",
args: ["uri": albumUri], args: [
"item_id": itemId,
"provider_instance_id_or_domain": provider
],
resultType: [MAMediaItem].self resultType: [MAMediaItem].self
) )
} }
/// Search library /// Search library
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] { 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 { if let mediaTypes {
args["media_types"] = mediaTypes.map { $0.rawValue } args["media_types"] = mediaTypes.map { $0.rawValue }
} }
return try await webSocketClient.sendCommand( // Try to get the response
"music/search", let response = try await webSocketClient.sendCommand("music/search", args: args)
args: args,
resultType: [MAMediaItem].self 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 // MARK: - Image Proxy
/// Build URL for image proxy /// Build URL for the MA image proxy.
func imageProxyURL(path: String, size: Int = 256) -> URL? { ///
guard let serverURL = authManager.serverURL else { return nil } /// 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 AZ az 09 - _ . ! ~ * ' ( ))
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)! var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
components.path = "/api/image_proxy" components.path = "/imageproxy"
components.queryItems = [
URLQueryItem(name: "path", value: path),
URLQueryItem(name: "size", value: String(size))
]
// 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 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")
}
} }
@@ -16,8 +16,8 @@ class MAThemeManager {
} }
init() { init() {
let savedValue = UserDefaults.standard.string(forKey: "appColorScheme") ?? "system" let savedValue = UserDefaults.standard.string(forKey: "appColorScheme") ?? "dark"
colorScheme = AppColorScheme(rawValue: savedValue) ?? .system colorScheme = AppColorScheme(rawValue: savedValue) ?? .dark
} }
var preferredColorScheme: ColorScheme? { 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 { var icon: String {
switch self { switch self {
case .system: case .system:
@@ -74,3 +85,21 @@ extension EnvironmentValues {
set { self[ThemeManagerKey.self] = newValue } 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())
}
}
@@ -100,17 +100,11 @@ final class MAWebSocketClient {
/// Connect to Music Assistant server /// Connect to Music Assistant server
func connect(serverURL: URL, authToken: String?) async throws { func connect(serverURL: URL, authToken: String?) async throws {
print("🔵 MAWebSocketClient.connect: Checking state")
guard connectionState == .disconnected else { guard connectionState == .disconnected else {
logger.info("Already connected or connecting") logger.info("Already connected or connecting")
print("⚠️ MAWebSocketClient.connect: Already connected/connecting, state = \(connectionState)")
return 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.serverURL = serverURL
self.authToken = authToken self.authToken = authToken
self.shouldReconnect = true self.shouldReconnect = true
@@ -120,50 +114,77 @@ final class MAWebSocketClient {
private func performConnect() async throws { private func performConnect() async throws {
guard let serverURL else { guard let serverURL else {
print("❌ MAWebSocketClient.performConnect: No server URL")
throw ClientError.invalidURL throw ClientError.invalidURL
} }
connectionState = .connecting connectionState = .connecting
logger.info("Connecting to \(serverURL.absoluteString)") logger.info("Connecting to \(serverURL.absoluteString)")
print("🔵 MAWebSocketClient.performConnect: Building WebSocket URL")
// Build WebSocket URL (ws:// or wss://) // Build WebSocket URL (ws:// or wss://)
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
let originalScheme = components.scheme
components.scheme = components.scheme == "https" ? "wss" : "ws" components.scheme = components.scheme == "https" ? "wss" : "ws"
components.path = "/ws" components.path = "/ws"
guard let wsURL = components.url else { guard let wsURL = components.url else {
print("❌ MAWebSocketClient.performConnect: Failed to build WebSocket URL")
throw ClientError.invalidURL throw ClientError.invalidURL
} }
print("🔵 MAWebSocketClient.performConnect: Original scheme = \(originalScheme ?? "nil")") logger.debug("WebSocket URL: \(wsURL.absoluteString)")
print("🔵 MAWebSocketClient.performConnect: WebSocket URL = \(wsURL.absoluteString)")
var request = URLRequest(url: wsURL) let task = session.webSocketTask(with: URLRequest(url: wsURL))
// Add auth token if available
if let authToken {
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
print("✅ MAWebSocketClient.performConnect: Authorization header added")
} else {
print("⚠️ MAWebSocketClient.performConnect: No auth token provided")
}
let task = session.webSocketTask(with: request)
self.webSocketTask = task self.webSocketTask = task
print("🔵 MAWebSocketClient.performConnect: Starting WebSocket task")
task.resume() task.resume()
// Start listening for messages do {
startReceiving() // 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 connectionState = .connected
logger.info("Connected successfully") logger.info("Connected successfully")
print("✅ MAWebSocketClient.performConnect: Connection successful")
} 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))
}
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 /// Disconnect from server
@@ -173,18 +194,20 @@ final class MAWebSocketClient {
reconnectTask?.cancel() reconnectTask?.cancel()
reconnectTask = nil 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 webSocketTask = nil
connectionState = .disconnected
task?.cancel(with: .goingAway, reason: nil)
// Cancel all pending requests // Cancel all pending requests
requestQueue.sync { requestQueue.sync {
for (messageId, continuation) in pendingRequests { for (_, continuation) in pendingRequests {
continuation.resume(throwing: ClientError.notConnected) continuation.resume(throwing: ClientError.notConnected)
} }
pendingRequests.removeAll() pendingRequests.removeAll()
} }
connectionState = .disconnected
eventContinuation?.finish() eventContinuation?.finish()
} }
@@ -199,10 +222,15 @@ final class MAWebSocketClient {
switch result { switch result {
case .success(let message): case .success(let message):
self.handleMessage(message) self.handleMessage(message)
// Continue listening // Only continue if we are still connected to this same task
if self.webSocketTask === task {
self.startReceiving() self.startReceiving()
}
case .failure(let error): 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)") logger.error("WebSocket receive error: \(error.localizedDescription)")
self.handleDisconnection() self.handleDisconnection()
} }
@@ -245,7 +273,7 @@ final class MAWebSocketClient {
// Check for error // Check for error
if let errorCode = response.errorCode { if let errorCode = response.errorCode {
let errorMsg = response.errorMessage ?? errorCode let errorMsg = response.details ?? "Error code: \(errorCode)"
continuation.resume(throwing: ClientError.serverError(errorMsg)) continuation.resume(throwing: ClientError.serverError(errorMsg))
} else { } else {
continuation.resume(returning: response) continuation.resume(returning: response)
@@ -259,8 +287,13 @@ final class MAWebSocketClient {
} }
private func handleDisconnection() { private func handleDisconnection() {
// Idempotency guard can be called from receive callback and disconnect() simultaneously
guard connectionState != .disconnected else { return }
connectionState = .disconnected connectionState = .disconnected
let task = webSocketTask
webSocketTask = nil webSocketTask = nil
task?.cancel(with: .goingAway, reason: nil)
// Cancel pending requests // Cancel pending requests
requestQueue.sync { requestQueue.sync {
@@ -372,8 +405,32 @@ final class MAWebSocketClient {
} }
do { 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) return try result.decode(as: T.self)
} catch { } 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) throw ClientError.decodingError(error)
} }
} }
@@ -70,6 +70,9 @@ private struct PickerPlayerCard: View {
let player: MAPlayer let player: MAPlayer
let onSelect: () -> Void 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? { private var currentItem: MAQueueItem? {
service.playerManager.playerQueues[player.playerId]?.currentItem service.playerManager.playerQueues[player.playerId]?.currentItem
} }
@@ -79,12 +82,12 @@ private struct PickerPlayerCard: View {
HStack(spacing: 12) { HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) { HStack(spacing: 6) {
if player.state == .playing { if livePlayer.state == .playing {
Image(systemName: "waveform") Image(systemName: "waveform")
.font(.caption) .font(.caption)
.foregroundStyle(.green) .foregroundStyle(.green)
} }
Text(player.name) Text(livePlayer.name)
.font(.headline) .font(.headline)
.foregroundStyle(.primary) .foregroundStyle(.primary)
.lineLimit(1) .lineLimit(1)
@@ -102,7 +105,7 @@ private struct PickerPlayerCard: View {
.lineLimit(1) .lineLimit(1)
} }
} else { } else {
Text(player.state == .off ? "Powered Off" : "No Track Playing") Text(livePlayer.state == .off ? "Powered Off" : "No Track Playing")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.lineLimit(1) .lineLimit(1)
@@ -148,13 +151,16 @@ private struct PickerGroupCard: View {
let memberNames: [String] let memberNames: [String]
let onSelect: () -> Void 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? { private var currentItem: MAQueueItem? {
service.playerManager.playerQueues[leader.playerId]?.currentItem service.playerManager.playerQueues[leader.playerId]?.currentItem
} }
private var mediaItem: MAMediaItem? { currentItem?.mediaItem } private var mediaItem: MAMediaItem? { currentItem?.mediaItem }
private var groupName: String { private var groupName: String {
([leader.name] + memberNames).joined(separator: " + ") ([liveLeader.name] + memberNames).joined(separator: " + ")
} }
var body: some View { var body: some View {
@@ -164,7 +170,7 @@ private struct PickerGroupCard: View {
Image(systemName: "speaker.2.fill") Image(systemName: "speaker.2.fill")
.font(.caption) .font(.caption)
.foregroundStyle(.blue) .foregroundStyle(.blue)
if leader.state == .playing { if liveLeader.state == .playing {
Image(systemName: "waveform") Image(systemName: "waveform")
.font(.caption) .font(.caption)
.foregroundStyle(.green) .foregroundStyle(.green)
@@ -187,7 +193,7 @@ private struct PickerGroupCard: View {
.lineLimit(1) .lineLimit(1)
} }
} else { } else {
Text(leader.state == .off ? "Powered Off" : "No Track Playing") Text(liveLeader.state == .off ? "Powered Off" : "No Track Playing")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.lineLimit(1) .lineLimit(1)
@@ -9,7 +9,6 @@ import SwiftUI
struct AlbumDetailView: View { struct AlbumDetailView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@Environment(\.audioPlayer) private var audioPlayer
let album: MAAlbum let album: MAAlbum
@State private var tracks: [MAMediaItem] = [] @State private var tracks: [MAMediaItem] = []
@@ -18,12 +17,23 @@ struct AlbumDetailView: View {
@State private var showError = false @State private var showError = false
@State private var showPlayerPicker = false @State private var showPlayerPicker = false
@State private var selectedPlayer: MAPlayer? @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] { 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 { var body: some View {
ZStack {
// Blurred Background with Ken Burns Effect
backgroundArtwork
// Content
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: 24) {
// Album Header // Album Header
@@ -33,24 +43,60 @@ struct AlbumDetailView: View {
playButton playButton
Divider() Divider()
.background(Color.white.opacity(0.3))
// Album description
if let albumDescription {
descriptionSection(albumDescription)
}
// Tracklist // Tracklist
if isLoading { if isLoading {
ProgressView() ProgressView()
.padding() .padding()
.tint(.white)
} else if tracks.isEmpty { } else if tracks.isEmpty {
Text("No tracks found") Text("No tracks found")
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.7))
.padding() .padding()
} else { } else {
trackList 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) .navigationTitle(album.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.task { .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) { .alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
@@ -62,76 +108,105 @@ struct AlbumDetailView: View {
.sheet(isPresented: $showPlayerPicker) { .sheet(isPresented: $showPlayerPicker) {
EnhancedPlayerPickerView( EnhancedPlayerPickerView(
players: players, players: players,
supportsLocalPlayback: audioPlayer != nil, onSelect: { player in
onSelect: { selection in Task { await playAlbum(on: player) }
Task {
switch selection {
case .localPlayer:
await playOnLocalPlayer()
case .remotePlayer(let player):
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 // MARK: - Album Header
@ViewBuilder @ViewBuilder
private var albumHeader: some View { private var albumHeader: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
// Cover Art // Cover Art
if let imageUrl = album.imageUrl { CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 512)) { image in
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} placeholder: { } placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 250, height: 250)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
} else {
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2)) .fill(Color.white.opacity(0.1))
.frame(width: 250, height: 250)
.overlay { .overlay {
Image(systemName: "opticaldisc") Image(systemName: "opticaldisc")
.font(.system(size: 60)) .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 // Album Info
VStack(spacing: 8) { VStack(spacing: 8) {
if let artists = album.artists, !artists.isEmpty { if let artists = album.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", ")) Text(artists.map { $0.name }.joined(separator: ", "))
.font(.title3) .font(.title3)
.foregroundStyle(.secondary) .fontWeight(.semibold)
.foregroundStyle(.white)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
} }
HStack { HStack {
if let year = album.year { if let year = album.year {
Text(String(year)) Text(String(year))
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.tertiary) .foregroundStyle(.white.opacity(0.8))
} }
if !tracks.isEmpty { if !tracks.isEmpty {
Text("") Text("")
.foregroundStyle(.tertiary) .foregroundStyle(.white.opacity(0.8))
Text("\(tracks.count) tracks") Text("\(tracks.count) tracks")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.tertiary) .foregroundStyle(.white.opacity(0.8))
} }
} }
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
} }
.padding(.horizontal) .padding(.horizontal)
} }
@@ -156,12 +231,24 @@ struct AlbumDetailView: View {
.font(.headline) .font(.headline)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
.background(Color.accentColor) .background(
LinearGradient(
colors: [Color.white.opacity(0.3), Color.white.opacity(0.2)],
startPoint: .top,
endPoint: .bottom
)
)
.foregroundStyle(.white) .foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12)) .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) .padding(.horizontal)
.disabled(tracks.isEmpty || players.isEmpty) .disabled(tracks.isEmpty || players.isEmpty)
.opacity((tracks.isEmpty || players.isEmpty) ? 0.5 : 1.0)
} }
// MARK: - Track List // MARK: - Track List
@@ -170,7 +257,7 @@ struct AlbumDetailView: View {
private var trackList: some View { private var trackList: some View {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in 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()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
if players.count == 1 { if players.count == 1 {
@@ -184,29 +271,93 @@ struct AlbumDetailView: View {
if index < tracks.count - 1 { if index < tracks.count - 1 {
Divider() Divider()
.background(Color.white.opacity(0.2))
.padding(.leading, 60) .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)) .clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal) .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 // MARK: - Actions
private func loadTracks() async { private func loadTracks() async {
print("🔵 AlbumDetailView: Loading tracks for album: \(album.name)")
print("🔵 AlbumDetailView: Album URI: \(album.uri)")
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
do { do {
tracks = try await service.libraryManager.getAlbumTracks(albumUri: album.uri) tracks = try await service.libraryManager.getAlbumTracks(albumUri: album.uri)
print("✅ AlbumDetailView: Loaded \(tracks.count) tracks")
isLoading = false isLoading = false
} catch { } catch {
print("❌ AlbumDetailView: Failed to load tracks: \(error)")
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
showError = true showError = true
isLoading = false 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 { private func playAlbum(on player: MAPlayer) async {
@@ -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 { private func playTrack(_ track: MAMediaItem, on player: MAPlayer) async {
do { do {
try await service.playerManager.playMedia( try await service.playerManager.playMedia(
@@ -261,25 +390,27 @@ struct AlbumDetailView: View {
struct TrackRow: View { struct TrackRow: View {
let track: MAMediaItem let track: MAMediaItem
let trackNumber: Int let trackNumber: Int
var useLightTheme: Bool = false
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
// Track Number // Track Number
Text("\(trackNumber)") Text("\(trackNumber)")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary)
.frame(width: 30, alignment: .trailing) .frame(width: 30, alignment: .trailing)
// Track Info // Track Info
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(track.name) Text(track.name)
.font(.body) .font(.body)
.foregroundStyle(useLightTheme ? .white : .primary)
.lineLimit(1) .lineLimit(1)
if let artists = track.artists, !artists.isEmpty { if let artists = track.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", ")) Text(artists.map { $0.name }.joined(separator: ", "))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -290,7 +421,7 @@ struct TrackRow: View {
if let duration = track.duration { if let duration = track.duration {
Text(formatDuration(duration)) Text(formatDuration(duration))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary)
} }
} }
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -21,12 +21,14 @@ struct AlbumsView: View {
} }
private let columns = [ 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 { var body: some View {
ScrollView { ScrollView {
LazyVGrid(columns: columns, spacing: 16) { LazyVGrid(columns: columns, spacing: 8) {
ForEach(albums) { album in ForEach(albums) { album in
NavigationLink(value: album) { NavigationLink(value: album) {
AlbumGridItem(album: album) AlbumGridItem(album: album)
@@ -40,21 +42,17 @@ struct AlbumsView: View {
if isLoading { if isLoading {
ProgressView() ProgressView()
.gridCellColumns(columns.count) .gridCellColumns(columns.count)
.padding() .padding(.horizontal, 12)
.padding(.vertical, 8)
} }
} }
.padding() .padding()
} }
.navigationDestination(for: MAAlbum.self) { album in
AlbumDetailView(album: album)
}
.refreshable { .refreshable {
await loadAlbums(refresh: true) await loadAlbums(refresh: true)
} }
.task { .task {
if albums.isEmpty { await loadAlbums(refresh: !albums.isEmpty)
await loadAlbums(refresh: false)
}
} }
.alert("Error", isPresented: $showError) { .alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
@@ -100,36 +98,28 @@ struct AlbumGridItem: View {
let album: MAAlbum let album: MAAlbum
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 4) {
// Album Cover // Album Cover
if let imageUrl = album.imageUrl { CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 256)) { image in
let coverURL = service.imageProxyURL(path: imageUrl, size: 256)
CachedAsyncImage(url: coverURL) { image in
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} placeholder: { } placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 160, height: 160)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2)) .fill(Color.gray.opacity(0.2))
.frame(width: 160, height: 160)
.overlay { .overlay {
Image(systemName: "opticaldisc") Image(systemName: "opticaldisc")
.font(.system(size: 40)) .font(.system(size: 40))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 8))
// Album Info // Album Info
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(album.name) Text(album.name)
.font(.subheadline) .font(.caption)
.fontWeight(.medium) .fontWeight(.medium)
.lineLimit(2) .lineLimit(2)
.foregroundStyle(.primary) .foregroundStyle(.primary)
@@ -147,7 +137,7 @@ struct AlbumGridItem: View {
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
} }
.frame(width: 160, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
} }
@@ -12,35 +12,58 @@ struct ArtistDetailView: View {
let artist: MAArtist let artist: MAArtist
@State private var albums: [MAAlbum] = [] @State private var albums: [MAAlbum] = []
@State private var biography: String?
@State private var isLoading = true @State private var isLoading = true
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showError = false @State private var showError = false
@State private var kenBurnsScale: CGFloat = 1.0
@State private var isBiographyExpanded = false
var body: some View { var body: some View {
ZStack {
// Blurred Background with Ken Burns Effect
backgroundArtwork
// Content
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: 24) {
// Artist Header // Artist Header
artistHeader artistHeader
// Biography Section
if let biography {
biographySection(biography)
}
Divider() Divider()
.background(Color.white.opacity(0.3))
// Albums Section // Albums Section
if isLoading { if isLoading {
ProgressView() ProgressView()
.padding() .padding()
.tint(.white)
} else if albums.isEmpty { } else if albums.isEmpty {
Text("No albums found") Text("No albums found")
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.7))
.padding() .padding()
} else { } else {
albumGrid albumGrid
} }
} }
} }
}
.navigationTitle(artist.name) .navigationTitle(artist.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.task { .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) { .alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { } 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 // MARK: - Artist Header
@ViewBuilder @ViewBuilder
private var artistHeader: some View { private var artistHeader: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
if let imageUrl = artist.imageUrl { CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 512)) { image in
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} placeholder: { } placeholder: {
Circle() Circle()
.fill(Color.gray.opacity(0.2)) .fill(Color.white.opacity(0.1))
}
.frame(width: 200, height: 200)
.clipShape(Circle())
.shadow(radius: 10)
} else {
Circle()
.fill(Color.gray.opacity(0.2))
.frame(width: 200, height: 200)
.overlay { .overlay {
Image(systemName: "music.mic") Image(systemName: "music.mic")
.font(.system(size: 60)) .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 { if !albums.isEmpty {
Text("\(albums.count) albums") Text("\(albums.count) albums")
.font(.subheadline) .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) .padding(.top)
@@ -96,6 +156,8 @@ struct ArtistDetailView: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Albums") Text("Albums")
.font(.title2.bold()) .font(.title2.bold())
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
.padding(.horizontal) .padding(.horizontal)
LazyVGrid( LazyVGrid(
@@ -103,30 +165,77 @@ struct ArtistDetailView: View {
spacing: 16 spacing: 16
) { ) {
ForEach(albums) { album in ForEach(albums) { album in
NavigationLink(destination: AlbumDetailView(album: album)) { NavigationLink(value: album) {
ArtistAlbumCard(album: album, service: service) ArtistAlbumCard(album: album, service: service)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.padding(.horizontal) .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 // MARK: - Actions
private func loadAlbums() async { private func loadAlbums() async {
print("🔵 ArtistDetailView: Loading albums for artist: \(artist.name)")
print("🔵 ArtistDetailView: Artist URI: \(artist.uri)")
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
do { do {
albums = try await service.libraryManager.getArtistAlbums(artistUri: artist.uri) albums = try await service.libraryManager.getArtistAlbums(artistUri: artist.uri)
print("✅ ArtistDetailView: Loaded \(albums.count) albums")
isLoading = false isLoading = false
} catch { } catch {
print("❌ ArtistDetailView: Failed to load albums: \(error)")
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
showError = true showError = true
isLoading = false 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 // MARK: - Artist Album Card
@@ -137,40 +246,45 @@ private struct ArtistAlbumCard: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let imageUrl = album.imageUrl { CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 256)) { image in
let coverURL = service.imageProxyURL(path: imageUrl, size: 256)
CachedAsyncImage(url: coverURL) { image in
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} placeholder: { } placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.2)) .fill(Color.white.opacity(0.1))
.aspectRatio(1, contentMode: .fit)
.overlay { .overlay {
Image(systemName: "opticaldisc") Image(systemName: "opticaldisc")
.font(.system(size: 36)) .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) Text(album.name)
.font(.caption.bold()) .font(.caption.bold())
.lineLimit(2) .lineLimit(2)
.foregroundStyle(.primary) .foregroundStyle(.white)
.shadow(color: .black.opacity(0.3), radius: 1, x: 0, y: 1)
if let year = album.year { if let year = album.year {
Text(String(year)) Text(String(year))
.font(.caption2) .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)
)
)
} }
} }
@@ -22,7 +22,9 @@ struct ArtistsView: View {
} }
private let columns = [ 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 "#" /// Artists grouped by first letter; non-alphabetic names go under "#"
@@ -42,13 +44,15 @@ struct ArtistsView: View {
artistsByLetter.map { $0.0 } artistsByLetter.map { $0.0 }
} }
// Always show AZ plus # so the sidebar has a consistent full height
private let allLetters: [String] = (65...90).map { String(UnicodeScalar($0)!) } + ["#"]
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ZStack(alignment: .trailing) {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(artistsByLetter, id: \.0) { letter, letterArtists in ForEach(artistsByLetter, id: \.0) { letter, letterArtists in
// Section header // Section header scroll target
Text(letter) Text(letter)
.font(.headline) .font(.headline)
.fontWeight(.bold) .fontWeight(.bold)
@@ -80,19 +84,22 @@ struct ArtistsView: View {
.padding() .padding()
} }
} }
// Right padding leaves room for the alphabet index .padding(.trailing, 28)
.padding(.trailing, 24)
} }
.overlay(alignment: .trailing) {
// Floating alphabet index on the right edge AlphabetIndexView(
if !availableLetters.isEmpty { letters: allLetters,
AlphabetIndexView(letters: availableLetters) { letter in itemHeight: 17,
proxy.scrollTo(letter, anchor: .top) 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) .padding(.trailing, 2)
} }
} }
}
.refreshable { .refreshable {
await loadArtists(refresh: true) await loadArtists(refresh: true)
} }
@@ -140,35 +147,34 @@ struct ArtistsView: View {
struct AlphabetIndexView: View { struct AlphabetIndexView: View {
let letters: [String] let letters: [String]
let itemHeight: CGFloat
let onSelect: (String) -> Void let onSelect: (String) -> Void
@State private var activeLetter: String? @State private var activeLetter: String?
var body: some View { var body: some View {
GeometryReader { geometry in
let itemHeight = geometry.size.height / CGFloat(letters.count)
ZStack {
// Touch-responsive column
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(letters, id: \.self) { letter in ForEach(letters, id: \.self) { letter in
Text(letter) Text(letter)
.font(.system(size: 11, weight: .bold)) .font(.system(size: min(13, itemHeight * 0.65), weight: .bold))
.frame(width: 20, height: itemHeight) .frame(width: 20, height: itemHeight)
.padding(.top, letter == "#" ? 6 : 0)
.foregroundStyle(activeLetter == letter ? .white : .accentColor) .foregroundStyle(activeLetter == letter ? .white : .accentColor)
.background { .background {
if activeLetter == letter { if activeLetter == letter {
Circle() Circle()
.fill(Color.accentColor) .fill(Color.accentColor)
.frame(width: 18, height: 18) .frame(width: min(18, itemHeight - 2), height: min(18, itemHeight - 2))
} }
} }
} }
} }
.contentShape(Rectangle())
.gesture( .gesture(
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)
.onChanged { value in .onChanged { value in
let index = Int(value.location.y / itemHeight) let safeHeight = max(1, itemHeight)
let index = Int(value.location.y / safeHeight)
let clamped = max(0, min(letters.count - 1, index)) let clamped = max(0, min(letters.count - 1, index))
let letter = letters[clamped] let letter = letters[clamped]
if activeLetter != letter { if activeLetter != letter {
@@ -182,9 +188,6 @@ struct AlphabetIndexView: View {
} }
) )
} }
}
.frame(width: 20)
}
} }
// MARK: - Artist Grid Item // MARK: - Artist Grid Item
@@ -195,7 +198,7 @@ struct ArtistGridItem: View {
var body: some View { var body: some View {
VStack(spacing: 4) { 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 image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
@@ -204,11 +207,11 @@ struct ArtistGridItem: View {
.fill(Color.gray.opacity(0.2)) .fill(Color.gray.opacity(0.2))
.overlay { .overlay {
Image(systemName: "music.mic") Image(systemName: "music.mic")
.font(.system(size: 22)) .font(.system(size: 30))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.frame(width: 76, height: 76) .aspectRatio(1, contentMode: .fit)
.clipShape(Circle()) .clipShape(Circle())
Text(artist.name) Text(artist.name)
@@ -17,6 +17,7 @@ enum LibraryTab: String, CaseIterable {
struct LibraryView: View { struct LibraryView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@State private var selectedTab: LibraryTab = .artists @State private var selectedTab: LibraryTab = .artists
@State private var showSearch = false
@State private var refreshError: String? @State private var refreshError: String?
@State private var showError = false @State private var showError = false
@@ -75,22 +76,28 @@ struct LibraryView: View {
} }
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
NavigationLink { Button {
SearchView() showSearch = true
} label: { } label: {
Label("Search", systemImage: "magnifyingglass") Label("Search", systemImage: "magnifyingglass")
} }
} }
} }
.navigationDestination(for: MAArtist.self) { ArtistDetailView(artist: $0) } .withMANavigation()
.navigationDestination(for: MAAlbum.self) { AlbumDetailView(album: $0) }
.navigationDestination(for: MAPlaylist.self) { PlaylistDetailView(playlist: $0) }
.alert("Refresh Failed", isPresented: $showError) { .alert("Refresh Failed", isPresented: $showError) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
} message: { } message: {
if let refreshError { Text(refreshError) } 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 // MARK: - Helpers
@@ -17,30 +17,22 @@ struct PlaylistDetailView: View {
// Playlist Header // Playlist Header
VStack(spacing: 16) { VStack(spacing: 16) {
// Playlist Cover // Playlist Cover
if let imageUrl = playlist.imageUrl { CachedAsyncImage(url: service.imageProxyURL(path: playlist.imageUrl, provider: playlist.imageProvider, size: 512)) { image in
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} placeholder: { } placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 250, height: 250)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
} else {
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2)) .fill(Color.gray.opacity(0.2))
.frame(width: 250, height: 250)
.overlay { .overlay {
Image(systemName: "music.note.list") Image(systemName: "music.note.list")
.font(.system(size: 60)) .font(.system(size: 60))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.frame(width: 250, height: 250)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
// Playlist Info // Playlist Info
VStack(spacing: 8) { VStack(spacing: 8) {
@@ -41,16 +41,11 @@ struct PlaylistsView: View {
.listStyle(.plain) .listStyle(.plain)
} }
} }
.navigationDestination(for: MAPlaylist.self) { playlist in
PlaylistDetailView(playlist: playlist)
}
.refreshable { .refreshable {
await loadPlaylists(refresh: true) await loadPlaylists(refresh: true)
} }
.task { .task {
if playlists.isEmpty { await loadPlaylists(refresh: !playlists.isEmpty)
await loadPlaylists(refresh: false)
}
} }
.alert("Error", isPresented: $showError) { .alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
@@ -80,29 +75,21 @@ struct PlaylistRow: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
// Playlist Cover // Playlist Cover
if let imageUrl = playlist.imageUrl { CachedAsyncImage(url: service.imageProxyURL(path: playlist.imageUrl, provider: playlist.imageProvider, size: 128)) { image in
let coverURL = service.imageProxyURL(path: imageUrl, size: 128)
CachedAsyncImage(url: coverURL) { image in
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} placeholder: { } placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 64, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2)) .fill(Color.gray.opacity(0.2))
.frame(width: 64, height: 64)
.overlay { .overlay {
Image(systemName: "music.note.list") Image(systemName: "music.note.list")
.font(.title2) .font(.title2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.frame(width: 64, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 8))
// Playlist Info // Playlist Info
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -18,7 +18,9 @@ struct RadiosView: View {
@State private var selectedRadio: MAMediaItem? @State private var selectedRadio: MAMediaItem?
private var players: [MAPlayer] { 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 { var body: some View {
@@ -56,9 +58,12 @@ struct RadiosView: View {
} }
.sheet(isPresented: $showPlayerPicker) { .sheet(isPresented: $showPlayerPicker) {
if let radio = selectedRadio { if let radio = selectedRadio {
PlayerPickerView(players: players) { player in EnhancedPlayerPickerView(
players: players,
onSelect: { player in
Task { await playRadio(radio, on: player) } Task { await playRadio(radio, on: player) }
} }
)
} }
} }
} }
@@ -84,6 +89,7 @@ struct RadiosView: View {
showError = true showError = true
} }
} }
} }
// MARK: - Radio Row // MARK: - Radio Row
@@ -94,23 +100,18 @@ private struct RadioRow: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
if let imageUrl = radio.imageUrl { CachedAsyncImage(url: service.imageProxyURL(path: radio.imageUrl, provider: radio.imageProvider, size: 128)) { image in
CachedAsyncImage(url: service.imageProxyURL(path: imageUrl, size: 128)) { image in
image.resizable().aspectRatio(contentMode: .fill) image.resizable().aspectRatio(contentMode: .fill)
} placeholder: { } placeholder: {
RoundedRectangle(cornerRadius: 8).fill(Color.gray.opacity(0.2))
}
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2)) .fill(Color.gray.opacity(0.2))
.frame(width: 50, height: 50)
.overlay { .overlay {
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(radio.name) Text(radio.name)
.font(.body) .font(.body)
@@ -21,7 +21,6 @@ struct SearchView: View {
@State private var searchTask: Task<Void, Never>? @State private var searchTask: Task<Void, Never>?
var body: some View { var body: some View {
NavigationStack {
Group { Group {
if searchResults.isEmpty && !isSearching { if searchResults.isEmpty && !isSearching {
if searchText.isEmpty { if searchText.isEmpty {
@@ -45,6 +44,12 @@ struct SearchView: View {
} }
.navigationTitle("Search") .navigationTitle("Search")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.withMANavigation()
.searchable(text: $searchText, prompt: "Artists, albums, tracks...") .searchable(text: $searchText, prompt: "Artists, albums, tracks...")
.onChange(of: searchText) { _, newValue in .onChange(of: searchText) { _, newValue in
performSearch(query: newValue) performSearch(query: newValue)
@@ -57,24 +62,152 @@ struct SearchView: View {
} }
} }
} }
}
// MARK: - Search Results List // MARK: - Search Results List
@ViewBuilder @ViewBuilder
private var searchResultsList: some View { private var searchResultsList: some View {
List { List {
ForEach(searchResults) { item in // 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) SearchResultRow(item: item)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
// TODO: Navigate to detail view based on media type 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) .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 // MARK: - Search
private func performSearch(query: String) { private func performSearch(query: String) {
@@ -97,21 +230,31 @@ struct SearchView: View {
} }
private func executeSearch(query: String) async { private func executeSearch(query: String) async {
print("🔍 Starting search for: '\(query)'")
isSearching = true isSearching = true
errorMessage = nil errorMessage = nil
do { do {
let results = try await service.libraryManager.search(query: query) 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 { await MainActor.run {
searchResults = results searchResults = results
isSearching = false isSearching = false
print("✅ UI updated with \(searchResults.count) results")
} }
} catch { } catch {
await MainActor.run { await MainActor.run {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
showError = true showError = true
searchResults = []
isSearching = false isSearching = false
print("❌ Search Error: \(error)")
} }
} }
} }
@@ -126,28 +269,20 @@ struct SearchResultRow: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
// Thumbnail // Thumbnail
if let imageUrl = item.imageUrl { CachedAsyncImage(url: service.imageProxyURL(path: item.imageUrl, provider: item.imageProvider, size: 128)) { image in
let coverURL = service.imageProxyURL(path: imageUrl, size: 128)
CachedAsyncImage(url: coverURL) { image in
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} placeholder: { } placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 60, height: 60)
.clipShape(thumbnailShape)
} else {
thumbnailShape thumbnailShape
.fill(Color.gray.opacity(0.2)) .fill(Color.gray.opacity(0.2))
.frame(width: 60, height: 60)
.overlay { .overlay {
Image(systemName: mediaTypeIcon) Image(systemName: mediaTypeIcon)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.frame(width: 60, height: 60)
.clipShape(thumbnailShape)
// Item Info // Item Info
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -167,7 +302,7 @@ struct SearchResultRow: View {
.lineLimit(1) .lineLimit(1)
} }
Label(item.mediaType.rawValue.capitalized, systemImage: mediaTypeIcon) Label((item.mediaType?.rawValue ?? "unknown").capitalized, systemImage: mediaTypeIcon)
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
@@ -178,12 +313,10 @@ struct SearchResultRow: View {
} }
private var thumbnailShape: some Shape { private var thumbnailShape: some Shape {
switch item.mediaType { if item.mediaType == .artist {
case .artist:
return AnyShape(Circle()) return AnyShape(Circle())
default:
return AnyShape(RoundedRectangle(cornerRadius: 8))
} }
return AnyShape(RoundedRectangle(cornerRadius: 8))
} }
private var mediaTypeIcon: String { private var mediaTypeIcon: String {
@@ -193,6 +326,7 @@ struct SearchResultRow: View {
case .artist: return "music.mic" case .artist: return "music.mic"
case .playlist: return "music.note.list" case .playlist: return "music.note.list"
case .radio: return "antenna.radiowaves.left.and.right" case .radio: return "antenna.radiowaves.left.and.right"
default: return "questionmark"
} }
} }
} }
@@ -107,6 +107,7 @@ struct LoginView: View {
} }
} }
} }
.applyTheme()
} }
// MARK: - Computed Properties // MARK: - Computed Properties
+311 -91
View File
@@ -12,24 +12,24 @@ struct MainTabView: View {
var body: some View { var body: some View {
TabView { TabView {
Tab("Players", systemImage: "speaker.wave.2.fill") {
PlayerListView()
}
Tab("Library", systemImage: "music.note.list") { Tab("Library", systemImage: "music.note.list") {
LibraryView() LibraryView()
} }
Tab("Players", systemImage: "speaker.wave.2.fill") {
PlayerListView()
}
Tab("Settings", systemImage: "gear") { Tab("Settings", systemImage: "gear") {
SettingsView() SettingsView()
} }
} }
.task { .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() service.playerManager.startListening()
try? await service.playerManager.loadPlayers()
} }
.onDisappear { .onDisappear {
// Stop listening when view disappears
service.playerManager.stopListening() service.playerManager.stopListening()
} }
} }
@@ -41,11 +41,31 @@ struct PlayerListView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var nowPlayingPlayer: MAPlayer?
private var players: [MAPlayer] { private var allPlayers: [MAPlayer] {
Array(service.playerManager.players.values).sorted { $0.name < $1.name } 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<String> {
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 { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
@@ -57,20 +77,39 @@ struct PlayerListView: View {
systemImage: "exclamationmark.triangle", systemImage: "exclamationmark.triangle",
description: Text(errorMessage) description: Text(errorMessage)
) )
} else if players.isEmpty { } else if !hasContent {
ContentUnavailableView( ContentUnavailableView(
"No Players Found", "No Players Found",
systemImage: "speaker.slash", systemImage: "speaker.slash",
description: Text("Make sure your Music Assistant server has configured players") description: Text("Make sure your Music Assistant server has configured players")
) )
} else { } else {
List(players) { player in ScrollView {
NavigationLink(value: player.playerId) { // VStack (not Lazy) ensures all drop targets are always rendered
PlayerRow(player: player) 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 { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { Button {
Task { Task { await loadPlayers() }
await loadPlayers()
}
} label: { } label: {
Label("Refresh", systemImage: "arrow.clockwise") Label("Refresh", systemImage: "arrow.clockwise")
} }
} }
} }
.withMANavigation()
.task { .task {
await loadPlayers() await loadPlayers()
} }
.sheet(item: $nowPlayingPlayer) { selectedPlayer in
PlayerNowPlayingView(playerId: selectedPlayer.playerId)
.environment(service)
}
} }
} }
private func loadPlayers() async { private func loadPlayers() async {
print("🔵 PlayerListView: Starting to load players...")
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
do { do {
print("🔵 PlayerListView: Calling playerManager.loadPlayers()")
try await service.playerManager.loadPlayers() try await service.playerManager.loadPlayers()
print("✅ PlayerListView: Successfully loaded \(players.count) players")
} catch { } catch {
print("❌ PlayerListView: Failed to load players: \(error)")
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
} }
isLoading = false isLoading = false
} }
} }
struct PlayerRow: View { // MARK: - Player Group Row
struct PlayerGroupRow: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
let player: MAPlayer 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 { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
// Album Art Thumbnail
if let item = player.currentItem,
let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 64)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
.frame(width: 48, height: 48)
.overlay {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
.font(.caption)
}
}
// Player Info
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(player.name)
.font(.headline)
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: stateIcon) Image(systemName: "speaker.2.fill")
.foregroundStyle(stateColor)
.font(.caption) .font(.caption)
Text(player.state.rawValue.capitalized) .foregroundStyle(.blue)
if leader.state == .playing {
Image(systemName: "waveform")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.green)
}
if let item = player.currentItem { Text(groupName)
Text("\(item.name)") .font(.headline)
.font(.caption) .foregroundStyle(.primary)
.foregroundStyle(.secondary)
.lineLimit(1) .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() Spacer()
// Volume Indicator // Play/pause
if player.available { Button {
VStack(spacing: 2) { Task {
Image(systemName: "speaker.wave.2.fill") if leader.state == .playing {
.font(.caption) try? await service.playerManager.pause(playerId: leader.playerId)
.foregroundStyle(.secondary) } else {
Text("\(player.volume)%") try? await service.playerManager.play(playerId: leader.playerId)
.font(.caption2)
.foregroundStyle(.secondary)
} }
} }
} 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)
} }
.padding(.vertical, 4) .buttonStyle(.plain)
}
private var stateIcon: String { // Dissolve group button
switch player.state { Button(action: onDissolve) {
case .playing: return "play.circle.fill" Image(systemName: "xmark.circle")
case .paused: return "pause.circle.fill" .font(.system(size: 22))
case .idle: return "stop.circle" .foregroundStyle(.red.opacity(0.7))
case .off: return "power.circle" }
.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))
private var stateColor: Color { .contentShape(RoundedRectangle(cornerRadius: 16))
switch player.state { .onTapGesture { onTap() }
case .playing: return .green
case .paused: return .orange
case .idle: return .gray
case .off: return .red
}
} }
} }
// Removed - Now using dedicated PlayerView.swift file // 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) {
// 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: {
Color.clear
}
.blur(radius: 20)
.scaleEffect(1.1)
.clipped()
Rectangle().fill(.ultraThinMaterial)
}
}
.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))
)
}
}
.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 LibraryView.swift file // Removed - Now using dedicated LibraryView.swift file
struct SettingsView: View { struct SettingsView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@Environment(\.themeManager) private var themeManager
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { 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 { Section {
if let serverURL = service.authManager.serverURL { if let serverURL = service.authManager.serverURL {
LabeledContent("Server", value: serverURL.absoluteString) LabeledContent("Server", value: serverURL.absoluteString)
@@ -224,8 +441,11 @@ struct SettingsView: View {
Text(service.isConnected ? "Connected" : "Disconnected") Text(service.isConnected ? "Connected" : "Disconnected")
} }
} }
} header: {
Text("Connection")
} }
// Actions Section
Section { Section {
Button(role: .destructive) { Button(role: .destructive) {
service.disconnect() service.disconnect()
@@ -29,6 +29,7 @@ struct RootView: View {
LoginView() LoginView()
} }
} }
.applyTheme()
.task { .task {
await initializeConnection() await initializeConnection()
} }
+101 -64
View File
@@ -12,48 +12,35 @@ struct PlayerNowPlayingView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let playerId: String 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 // Auto-tracks live updates via @Observable
private var player: MAPlayer? { private var player: MAPlayer? {
service.playerManager.players[playerId] service.playerManager.players[playerId]
} }
private var currentItem: MAQueueItem? {
service.playerManager.playerQueues[playerId]?.currentItem
}
private var mediaItem: MAMediaItem? { private var mediaItem: MAMediaItem? {
player?.currentItem?.mediaItem currentItem?.mediaItem
} }
var body: some View { var body: some View {
ZStack { // ScrollView is the root fills the sheet top-to-bottom, no centering
// Blurred artwork background ScrollView {
CachedAsyncImage(url: service.imageProxyURL( VStack(spacing: 16) {
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)
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
// Content
VStack(spacing: 0) {
// Drag indicator // Drag indicator
Capsule() Capsule()
.fill(.secondary.opacity(0.4)) .fill(.secondary.opacity(0.4))
.frame(width: 36, height: 4) .frame(width: 36, height: 4)
.padding(.top, 12) .padding(.top, 8)
.padding(.bottom, 8)
// Player name // Header: dismiss + player name
HStack { HStack {
Button { dismiss() } label: { Button { dismiss() } label: {
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
@@ -78,16 +65,12 @@ struct PlayerNowPlayingView: View {
Spacer() Spacer()
// Balance chevron button
Color.clear Color.clear
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.bottom, 8)
// Album art // Album art
GeometryReader { geo in
let size = min(geo.size.width - 64, geo.size.height)
CachedAsyncImage(url: service.imageProxyURL( CachedAsyncImage(url: service.imageProxyURL(
path: mediaItem?.imageUrl, path: mediaItem?.imageUrl,
provider: mediaItem?.imageProvider, provider: mediaItem?.imageProvider,
@@ -95,27 +78,22 @@ struct PlayerNowPlayingView: View {
)) { image in )) { image in
image image
.resizable() .resizable()
.aspectRatio(1, contentMode: .fill) .scaledToFill()
} placeholder: { } placeholder: {
RoundedRectangle(cornerRadius: 16) Color.gray.opacity(0.2)
.fill(Color.gray.opacity(0.2))
.overlay { .overlay {
Image(systemName: "music.note") Image(systemName: "music.note")
.font(.system(size: 56)) .font(.system(size: 56))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.frame(width: size, height: size) .frame(width: 260, height: 260)
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.35), radius: 24, y: 12) .shadow(color: .black.opacity(0.35), radius: 24, y: 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.horizontal, 32)
.padding(.vertical, 24)
// Track info // Track info
VStack(spacing: 6) { VStack(spacing: 6) {
Text(player?.currentItem?.name ?? "") Text(currentItem?.name ?? "")
.font(.title2) .font(.title2)
.fontWeight(.bold) .fontWeight(.bold)
.lineLimit(2) .lineLimit(2)
@@ -135,8 +113,6 @@ struct PlayerNowPlayingView: View {
} }
.padding(.horizontal, 32) .padding(.horizontal, 32)
Spacer(minLength: 24)
// Transport controls // Transport controls
if let player { if let player {
HStack(spacing: 48) { HStack(spacing: 48) {
@@ -174,44 +150,105 @@ struct PlayerNowPlayingView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
Spacer(minLength: 24) // Volume control
// Volume
if let volume = player?.volume {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: "speaker.fill") // Mute toggle
.font(.caption) Button { handleMute() } label: {
.foregroundStyle(.secondary) Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.slash")
.frame(width: 20) .font(.system(size: 15))
.foregroundStyle(isMuted ? .primary : .secondary)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
Slider( // Volume down 5
value: Binding( Button { adjustVolume(by: -5) } label: {
get: { Double(volume) }, Image(systemName: "speaker.fill")
set: { newValue in .font(.system(size: 13))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
Slider(value: $localVolume, in: 0...100, step: 1) { editing in
isVolumeEditing = editing
if !editing {
Task { Task {
try? await service.playerManager.setVolume( try? await service.playerManager.setVolume(
playerId: playerId, playerId: playerId,
level: Int(newValue) level: Int(localVolume)
) )
} }
} }
), }
in: 0...100,
step: 1
)
// Volume up +5
Button { adjustVolume(by: 5) } label: {
Image(systemName: "speaker.wave.3.fill") Image(systemName: "speaker.wave.3.fill")
.font(.caption) .font(.system(size: 20))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 20) }
.buttonStyle(.plain)
} }
.padding(.horizontal, 32) .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)
Spacer(minLength: 32) 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]) .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) }
}
} }
} }