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