Queue, Favorites, Providers, Now playing

This commit is contained in:
2026-04-06 11:46:04 +02:00
parent e7e9a59e70
commit 56199db301
12 changed files with 462 additions and 58 deletions
@@ -7,10 +7,14 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */; };
2681ED712F8399A9002FB204 /* ViewsComponentsProviderBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */; };
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; }; 26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerQueueView.swift; sourceTree = "<group>"; };
2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsComponentsProviderBadge.swift; sourceTree = "<group>"; };
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerNowPlayingView.swift; sourceTree = "<group>"; }; 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerNowPlayingView.swift; sourceTree = "<group>"; };
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -40,6 +44,8 @@
26ED92632F759EEA0025419D /* Mobile Music Assistant */, 26ED92632F759EEA0025419D /* Mobile Music Assistant */,
26ED92622F759EEA0025419D /* Products */, 26ED92622F759EEA0025419D /* Products */,
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */, 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */,
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */,
2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -125,6 +131,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */,
2681ED712F8399A9002FB204 /* ViewsComponentsProviderBadge.swift in Sources */,
26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */, 26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -272,7 +280,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -308,7 +316,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
+26 -6
View File
@@ -184,20 +184,22 @@ struct MAMediaItem: Codable, Identifiable, Hashable {
let album: MAAlbum? let album: MAAlbum?
let metadata: MediaItemMetadata? let metadata: MediaItemMetadata?
let duration: Int? let duration: Int?
let favorite: Bool
var id: String { uri } var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path } var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider } var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case uri, name, duration, artists, album, metadata case uri, name, duration, artists, album, metadata, favorite
case mediaType = "media_type" case mediaType = "media_type"
case image // Direct image field from search results 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) { init(uri: String, name: String, mediaType: MediaType? = nil, artists: [MAArtist]? = nil, album: MAAlbum? = nil, imageUrl: String? = nil, duration: Int? = nil, favorite: Bool = false) {
self.uri = uri; self.name = name; self.mediaType = mediaType self.uri = uri; self.name = name; self.mediaType = mediaType
self.artists = artists; self.album = album; self.duration = duration self.artists = artists; self.album = album; self.duration = duration
self.favorite = favorite
self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: nil, remotelyAccessible: nil)], cacheChecksum: nil) } self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: nil, remotelyAccessible: nil)], cacheChecksum: nil) }
} }
@@ -205,6 +207,7 @@ struct MAMediaItem: Codable, Identifiable, Hashable {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
uri = try c.decode(String.self, forKey: .uri) uri = try c.decode(String.self, forKey: .uri)
name = try c.decode(String.self, forKey: .name) name = try c.decode(String.self, forKey: .name)
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
// Media type is critical - decode it first // Media type is critical - decode it first
let mediaTypeString = try? c.decodeIfPresent(String.self, forKey: .mediaType) let mediaTypeString = try? c.decodeIfPresent(String.self, forKey: .mediaType)
@@ -248,6 +251,7 @@ struct MAMediaItem: Codable, Identifiable, Hashable {
try c.encodeIfPresent(album, forKey: .album) try c.encodeIfPresent(album, forKey: .album)
try c.encodeIfPresent(duration, forKey: .duration) try c.encodeIfPresent(duration, forKey: .duration)
try c.encodeIfPresent(metadata, forKey: .metadata) try c.encodeIfPresent(metadata, forKey: .metadata)
try c.encode(favorite, forKey: .favorite)
} }
} }
@@ -269,28 +273,31 @@ struct MAArtist: Codable, Identifiable, Hashable {
let metadata: MediaItemMetadata? let metadata: MediaItemMetadata?
let sortName: String? let sortName: String?
let musicbrainzId: String? let musicbrainzId: String?
let favorite: Bool
var id: String { uri } var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path } var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider } var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case uri, name, metadata case uri, name, metadata, favorite
case sortName = "sort_name" case sortName = "sort_name"
case musicbrainzId = "musicbrainz_id" case musicbrainzId = "musicbrainz_id"
case image // Direct image field case image // Direct image field
} }
init(uri: String, name: String, imageUrl: String? = nil, imageProvider: String? = nil, sortName: String? = nil, musicbrainzId: String? = nil) { init(uri: String, name: String, imageUrl: String? = nil, imageProvider: String? = nil, sortName: String? = nil, musicbrainzId: String? = nil, favorite: Bool = false) {
self.uri = uri; self.name = name self.uri = uri; self.name = name
self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) } self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) }
self.sortName = sortName; self.musicbrainzId = musicbrainzId self.sortName = sortName; self.musicbrainzId = musicbrainzId
self.favorite = favorite
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
uri = try c.decode(String.self, forKey: .uri) uri = try c.decode(String.self, forKey: .uri)
name = try c.decode(String.self, forKey: .name) name = try c.decode(String.self, forKey: .name)
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
sortName = try? c.decodeIfPresent(String.self, forKey: .sortName) sortName = try? c.decodeIfPresent(String.self, forKey: .sortName)
musicbrainzId = try? c.decodeIfPresent(String.self, forKey: .musicbrainzId) musicbrainzId = try? c.decodeIfPresent(String.self, forKey: .musicbrainzId)
@@ -314,6 +321,7 @@ struct MAArtist: Codable, Identifiable, Hashable {
try c.encodeIfPresent(sortName, forKey: .sortName) try c.encodeIfPresent(sortName, forKey: .sortName)
try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId) try c.encodeIfPresent(musicbrainzId, forKey: .musicbrainzId)
try c.encodeIfPresent(metadata, forKey: .metadata) try c.encodeIfPresent(metadata, forKey: .metadata)
try c.encode(favorite, forKey: .favorite)
} }
} }
@@ -323,18 +331,20 @@ struct MAAlbum: Codable, Identifiable, Hashable {
let artists: [MAArtist]? let artists: [MAArtist]?
let metadata: MediaItemMetadata? let metadata: MediaItemMetadata?
let year: Int? let year: Int?
let favorite: Bool
var id: String { uri } var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path } var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider } var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case uri, name, artists, metadata, year case uri, name, artists, metadata, year, favorite
case image // Direct image field case image // Direct image field
} }
init(uri: String, name: String, artists: [MAArtist]? = nil, imageUrl: String? = nil, imageProvider: String? = nil, year: Int? = nil) { init(uri: String, name: String, artists: [MAArtist]? = nil, imageUrl: String? = nil, imageProvider: String? = nil, year: Int? = nil, favorite: Bool = false) {
self.uri = uri; self.name = name; self.artists = artists; self.year = year self.uri = uri; self.name = name; self.artists = artists; self.year = year
self.favorite = favorite
self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) } self.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) }
} }
@@ -342,6 +352,7 @@ struct MAAlbum: Codable, Identifiable, Hashable {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
uri = try c.decode(String.self, forKey: .uri) uri = try c.decode(String.self, forKey: .uri)
name = try c.decode(String.self, forKey: .name) name = try c.decode(String.self, forKey: .name)
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
artists = try? c.decodeIfPresent([MAArtist].self, forKey: .artists) artists = try? c.decodeIfPresent([MAArtist].self, forKey: .artists)
year = try? c.decodeIfPresent(Int.self, forKey: .year) year = try? c.decodeIfPresent(Int.self, forKey: .year)
@@ -365,6 +376,7 @@ struct MAAlbum: Codable, Identifiable, Hashable {
try c.encodeIfPresent(artists, forKey: .artists) try c.encodeIfPresent(artists, forKey: .artists)
try c.encodeIfPresent(year, forKey: .year) try c.encodeIfPresent(year, forKey: .year)
try c.encodeIfPresent(metadata, forKey: .metadata) try c.encodeIfPresent(metadata, forKey: .metadata)
try c.encode(favorite, forKey: .favorite)
} }
} }
@@ -407,11 +419,17 @@ struct MAPlayerQueue: Codable {
let queueId: String let queueId: String
let currentItem: MAQueueItem? let currentItem: MAQueueItem?
let currentIndex: Int? let currentIndex: Int?
/// Seconds elapsed in current track (at the time of last update).
let elapsedTime: Double?
/// Unix timestamp when `elapsedTime` was last set by the server.
let elapsedTimeLastUpdated: Double?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case queueId = "queue_id" case queueId = "queue_id"
case currentItem = "current_item" case currentItem = "current_item"
case currentIndex = "current_index" case currentIndex = "current_index"
case elapsedTime = "elapsed_time"
case elapsedTimeLastUpdated = "elapsed_time_last_updated"
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -419,6 +437,8 @@ struct MAPlayerQueue: Codable {
queueId = try c.decode(String.self, forKey: .queueId) queueId = try c.decode(String.self, forKey: .queueId)
currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem) currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem)
currentIndex = try? c.decodeIfPresent(Int.self, forKey: .currentIndex) currentIndex = try? c.decodeIfPresent(Int.self, forKey: .currentIndex)
elapsedTime = try? c.decodeIfPresent(Double.self, forKey: .elapsedTime)
elapsedTimeLastUpdated = try? c.decodeIfPresent(Double.self, forKey: .elapsedTimeLastUpdated)
} }
} }
@@ -34,6 +34,10 @@ final class MALibraryManager {
private(set) var isLoadingAlbums = false private(set) var isLoadingAlbums = false
private(set) var isLoadingPlaylists = false private(set) var isLoadingPlaylists = false
/// URIs currently marked as favorites source of truth for UI.
/// Populated from decoded model data, then mutated optimistically on toggle.
private(set) var favoriteURIs: Set<String> = []
// Last refresh timestamps (persisted in UserDefaults) // Last refresh timestamps (persisted in UserDefaults)
private(set) var lastArtistsRefresh: Date? private(set) var lastArtistsRefresh: Date?
private(set) var lastAlbumsRefresh: Date? private(set) var lastAlbumsRefresh: Date?
@@ -42,7 +46,7 @@ final class MALibraryManager {
// MARK: - Disk Cache // MARK: - Disk Cache
/// Increment this whenever the model format changes to invalidate stale caches. /// Increment this whenever the model format changes to invalidate stale caches.
private static let cacheVersion = 2 private static let cacheVersion = 3
private let cacheDirectory: URL = { private let cacheDirectory: URL = {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
@@ -96,6 +100,10 @@ final class MALibraryManager {
logger.info("Loaded \(cached.count) playlists from disk cache") logger.info("Loaded \(cached.count) playlists from disk cache")
} }
// Seed favorite URIs from cached data
for artist in artists where artist.favorite { favoriteURIs.insert(artist.uri) }
for album in albums where album.favorite { favoriteURIs.insert(album.uri) }
let ud = UserDefaults.standard let ud = UserDefaults.standard
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
@@ -152,10 +160,13 @@ final class MALibraryManager {
if refresh { if refresh {
artists = newArtists artists = newArtists
artistsOffset = newArtists.count artistsOffset = newArtists.count
// Reset and repopulate artist favorites on refresh
for a in artists where a.favorite { favoriteURIs.insert(a.uri) }
} else { } else {
artists.append(contentsOf: newArtists) artists.append(contentsOf: newArtists)
artistsOffset += newArtists.count artistsOffset += newArtists.count
} }
for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) }
hasMoreArtists = newArtists.count >= pageSize hasMoreArtists = newArtists.count >= pageSize
if refresh || artistsOffset <= pageSize { if refresh || artistsOffset <= pageSize {
@@ -199,10 +210,12 @@ final class MALibraryManager {
if refresh { if refresh {
albums = newAlbums albums = newAlbums
albumsOffset = newAlbums.count albumsOffset = newAlbums.count
for a in albums where a.favorite { favoriteURIs.insert(a.uri) }
} else { } else {
albums.append(contentsOf: newAlbums) albums.append(contentsOf: newAlbums)
albumsOffset += newAlbums.count albumsOffset += newAlbums.count
} }
for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) }
hasMoreAlbums = newAlbums.count >= pageSize hasMoreAlbums = newAlbums.count >= pageSize
if refresh || albumsOffset <= pageSize { if refresh || albumsOffset <= pageSize {
@@ -255,6 +268,53 @@ final class MALibraryManager {
return try await service.getAlbumTracks(albumUri: albumUri) return try await service.getAlbumTracks(albumUri: albumUri)
} }
func getPlaylistTracks(playlistUri: String) async throws -> [MAMediaItem] {
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
logger.info("Loading tracks for playlist \(playlistUri)")
return try await service.getPlaylistTracks(playlistUri: playlistUri)
}
// MARK: - Favorites
/// Returns whether the given URI is currently favorited.
func isFavorite(uri: String) -> Bool {
favoriteURIs.contains(uri)
}
/// Toggle favorite for any item. Performs optimistic update, then calls server.
/// Reverts on failure.
func toggleFavorite(uri: String, currentlyFavorite: Bool) async {
// Optimistic update
if currentlyFavorite {
favoriteURIs.remove(uri)
} else {
favoriteURIs.insert(uri)
}
// Call server
guard let service else {
// Revert if no service
if currentlyFavorite { favoriteURIs.insert(uri) } else { favoriteURIs.remove(uri) }
return
}
do {
if currentlyFavorite {
try await service.removeFavorite(uri: uri)
} else {
try await service.addFavorite(uri: uri)
}
} catch {
// Revert on failure
if currentlyFavorite {
favoriteURIs.insert(uri)
} else {
favoriteURIs.remove(uri)
}
logger.error("Failed to toggle favorite for \(uri): \(error)")
}
}
// MARK: - Search // MARK: - Search
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] { func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
@@ -119,8 +119,25 @@ final class MAPlayerManager {
} }
private func handleQueueItemsUpdated(_ event: MAEvent) async { private func handleQueueItemsUpdated(_ event: MAEvent) async {
// Similar to queue_updated // Update queue state (current item, current index)
await handleQueueUpdated(event) await handleQueueUpdated(event)
// Reload the items list if we already have it cached (i.e., queue view was opened)
guard let data = event.data,
let dict = data.value as? [String: Any],
let queueId = dict["queue_id"] as? String,
queues[queueId] != nil,
let service else { return }
do {
let items = try await service.getQueue(playerId: queueId)
await MainActor.run {
queues[queueId] = items
logger.debug("Reloaded queue items for player \(queueId): \(items.count) items")
}
} catch {
logger.error("Failed to reload queue items: \(error.localizedDescription)")
}
} }
// MARK: - Data Loading // MARK: - Data Loading
@@ -236,6 +253,13 @@ final class MAPlayerManager {
try await service.playMedia(playerId: playerId, uri: uri) try await service.playMedia(playerId: playerId, uri: uri)
} }
func enqueueMedia(playerId: String, uri: String) async throws {
guard let service else {
throw MAWebSocketClient.ClientError.notConnected
}
try await service.enqueueMedia(playerId: playerId, uri: uri)
}
func playIndex(playerId: String, index: Int) async throws { func playIndex(playerId: String, index: Int) async throws {
guard let service else { guard let service else {
throw MAWebSocketClient.ClientError.notConnected throw MAWebSocketClient.ClientError.notConnected
@@ -193,6 +193,19 @@ final class MAService {
) )
} }
/// Add media item to the end of a player's queue
func enqueueMedia(playerId: String, uri: String) async throws {
logger.debug("Enqueuing media \(uri) on player \(playerId)")
_ = try await webSocketClient.sendCommand(
"player_queues/play_media",
args: [
"queue_id": playerId,
"media": [uri],
"option": "add"
]
)
}
/// Play from queue index /// Play from queue index
func playIndex(playerId: String, index: Int) async throws { func playIndex(playerId: String, index: Int) async throws {
logger.debug("Playing index \(index) on player \(playerId)") logger.debug("Playing index \(index) on player \(playerId)")
@@ -328,6 +341,22 @@ final class MAService {
) )
} }
/// Get playlist tracks
func getPlaylistTracks(playlistUri: String) async throws -> [MAMediaItem] {
logger.debug("Fetching tracks for playlist \(playlistUri)")
guard let (provider, itemId) = parseMAUri(playlistUri) else {
throw MAWebSocketClient.ClientError.serverError("Invalid playlist URI: \(playlistUri)")
}
return try await webSocketClient.sendCommand(
"music/playlists/playlist_tracks",
args: [
"item_id": itemId,
"provider_instance_id_or_domain": provider
],
resultType: [MAMediaItem].self
)
}
/// Get album tracks /// Get album tracks
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] { func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
logger.debug("Fetching tracks for album \(albumUri)") logger.debug("Fetching tracks for album \(albumUri)")
@@ -344,6 +373,36 @@ final class MAService {
) )
} }
// MARK: - Favorites
/// Add an item to favorites by URI
func addFavorite(uri: String) async throws {
logger.debug("Adding favorite: \(uri)")
_ = try await webSocketClient.sendCommand(
"music/favorites/add_item",
args: ["item": uri]
)
}
/// Remove an item from favorites.
/// The server expects media_type (e.g. "artist") and library_item_id (e.g. "123").
/// These are extracted from the URI format: library://artist/123
func removeFavorite(uri: String) async throws {
logger.debug("Removing favorite: \(uri)")
guard let url = URL(string: uri),
let host = url.host else {
throw MAWebSocketClient.ClientError.serverError("Invalid URI for favorite removal: \(uri)")
}
let itemId = url.path.isEmpty ? host : String(url.path.dropFirst())
_ = try await webSocketClient.sendCommand(
"music/favorites/remove_item",
args: [
"media_type": host,
"library_item_id": itemId
]
)
}
/// Search library /// Search library
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] { func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
logger.debug("🔍 Searching for '\(query)'") logger.debug("🔍 Searching for '\(query)'")
@@ -12,8 +12,15 @@ struct EnhancedPlayerPickerView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
let players: [MAPlayer] let players: [MAPlayer]
let title: String
let onSelect: (MAPlayer) -> Void let onSelect: (MAPlayer) -> Void
init(players: [MAPlayer], title: String = "Play on...", onSelect: @escaping (MAPlayer) -> Void) {
self.players = players
self.title = title
self.onSelect = onSelect
}
/// IDs of all players that are sync members (not the leader) /// IDs of all players that are sync members (not the leader)
private var syncedMemberIds: Set<String> { private var syncedMemberIds: Set<String> {
Set(players.flatMap { $0.groupChilds }) Set(players.flatMap { $0.groupChilds })
@@ -52,7 +59,7 @@ struct EnhancedPlayerPickerView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.navigationTitle("Play on...") .navigationTitle(title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
@@ -16,6 +16,7 @@ struct AlbumDetailView: View {
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showError = false @State private var showError = false
@State private var showPlayerPicker = false @State private var showPlayerPicker = false
@State private var showEnqueuePicker = false
@State private var selectedPlayer: MAPlayer? @State private var selectedPlayer: MAPlayer?
@State private var kenBurnsScale: CGFloat = 1.0 @State private var kenBurnsScale: CGFloat = 1.0
@State private var completeAlbum: MAAlbum? @State private var completeAlbum: MAAlbum?
@@ -28,6 +29,13 @@ struct AlbumDetailView: View {
.sorted { $0.name < $1.name } .sorted { $0.name < $1.name }
} }
/// URIs of tracks currently playing on any player.
private var nowPlayingURIs: Set<String> {
Set(service.playerManager.playerQueues.values.compactMap {
$0.currentItem?.mediaItem?.uri
})
}
var body: some View { var body: some View {
ZStack { ZStack {
// Blurred Background with Ken Burns Effect // Blurred Background with Ken Burns Effect
@@ -39,8 +47,8 @@ struct AlbumDetailView: View {
// Album Header // Album Header
albumHeader albumHeader
// Play Button // Action Buttons
playButton actionButtons
Divider() Divider()
.background(Color.white.opacity(0.3)) .background(Color.white.opacity(0.3))
@@ -90,6 +98,11 @@ struct AlbumDetailView: View {
.navigationTitle(album.name) .navigationTitle(album.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
FavoriteButton(uri: album.uri, size: 22, showInLight: true)
}
}
.task { .task {
async let tracksLoad: () = loadTracks() async let tracksLoad: () = loadTracks()
async let detailLoad: () = loadAlbumDetail() async let detailLoad: () = loadAlbumDetail()
@@ -113,6 +126,15 @@ struct AlbumDetailView: View {
} }
) )
} }
.sheet(isPresented: $showEnqueuePicker) {
EnhancedPlayerPickerView(
players: players,
title: "Add to Queue on...",
onSelect: { player in
Task { await enqueueAlbum(on: player) }
}
)
}
} }
// MARK: - Background Artwork // MARK: - Background Artwork
@@ -191,7 +213,9 @@ struct AlbumDetailView: View {
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
} }
HStack { HStack(spacing: 6) {
ProviderBadge(uri: album.uri, imageProvider: album.imageProvider)
if let year = album.year { if let year = album.year {
Text(String(year)) Text(String(year))
.font(.subheadline) .font(.subheadline)
@@ -213,21 +237,21 @@ struct AlbumDetailView: View {
.padding(.top) .padding(.top)
} }
// MARK: - Play Button // MARK: - Action Buttons
@ViewBuilder @ViewBuilder
private var playButton: some View { private var actionButtons: some View {
HStack(spacing: 12) {
// Play Album
Button { Button {
if players.count == 1 { if players.count == 1 {
selectedPlayer = players.first selectedPlayer = players.first
Task { Task { await playAlbum(on: players.first!) }
await playAlbum(on: players.first!)
}
} else { } else {
showPlayerPicker = true showPlayerPicker = true
} }
} label: { } label: {
Label("Play Album", systemImage: "play.fill") Label("Play", systemImage: "play.fill")
.font(.headline) .font(.headline)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
@@ -246,6 +270,35 @@ struct AlbumDetailView: View {
) )
.shadow(color: .black.opacity(0.3), radius: 10, y: 5) .shadow(color: .black.opacity(0.3), radius: 10, y: 5)
} }
// Add to Queue
Button {
if players.count == 1 {
Task { await enqueueAlbum(on: players.first!) }
} else {
showEnqueuePicker = true
}
} label: {
Label("Add to Queue", systemImage: "text.badge.plus")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(
LinearGradient(
colors: [Color.white.opacity(0.2), Color.white.opacity(0.1)],
startPoint: .top,
endPoint: .bottom
)
)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
.shadow(color: .black.opacity(0.3), radius: 10, y: 5)
}
}
.padding(.horizontal) .padding(.horizontal)
.disabled(tracks.isEmpty || players.isEmpty) .disabled(tracks.isEmpty || players.isEmpty)
.opacity((tracks.isEmpty || players.isEmpty) ? 0.5 : 1.0) .opacity((tracks.isEmpty || players.isEmpty) ? 0.5 : 1.0)
@@ -257,7 +310,7 @@ struct AlbumDetailView: View {
private var trackList: some View { private var trackList: some View {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
TrackRow(track: track, trackNumber: index + 1, useLightTheme: true) TrackRow(track: track, trackNumber: index + 1, useLightTheme: true, isPlaying: nowPlayingURIs.contains(track.uri))
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
if players.count == 1 { if players.count == 1 {
@@ -372,6 +425,18 @@ struct AlbumDetailView: View {
} }
} }
private func enqueueAlbum(on player: MAPlayer) async {
do {
try await service.playerManager.enqueueMedia(
playerId: player.playerId,
uri: album.uri
)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func playTrack(_ track: MAMediaItem, on player: MAPlayer) async { private func playTrack(_ track: MAMediaItem, on player: MAPlayer) async {
do { do {
try await service.playerManager.playMedia( try await service.playerManager.playMedia(
@@ -388,16 +453,27 @@ struct AlbumDetailView: View {
// MARK: - Track Row // MARK: - Track Row
struct TrackRow: View { struct TrackRow: View {
@Environment(MAService.self) private var service
let track: MAMediaItem let track: MAMediaItem
let trackNumber: Int let trackNumber: Int
var useLightTheme: Bool = false var useLightTheme: Bool = false
var isPlaying: Bool = false
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
// Track Number // Track Number / Now Playing indicator
Group {
if isPlaying {
Image(systemName: "waveform")
.font(.caption)
.foregroundStyle(.green)
.symbolEffect(.variableColor.iterative, isActive: true)
} else {
Text("\(trackNumber)") Text("\(trackNumber)")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary) .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary)
}
}
.frame(width: 30, alignment: .trailing) .frame(width: 30, alignment: .trailing)
// Track Info // Track Info
@@ -417,6 +493,9 @@ struct TrackRow: View {
Spacer() Spacer()
// Favorite
FavoriteButton(uri: track.uri, size: 16, showInLight: useLightTheme)
// Duration // Duration
if let duration = track.duration { if let duration = track.duration {
Text(formatDuration(duration)) Text(formatDuration(duration))
@@ -115,6 +115,15 @@ struct AlbumGridItem: View {
} }
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(alignment: .bottomTrailing) {
if service.libraryManager.isFavorite(uri: album.uri) {
Image(systemName: "heart.fill")
.font(.system(size: 12))
.foregroundStyle(.red)
.padding(6)
}
}
// Album Info // Album Info
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -56,6 +56,11 @@ struct ArtistDetailView: View {
.navigationTitle(artist.name) .navigationTitle(artist.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
FavoriteButton(uri: artist.uri, size: 22, showInLight: true)
}
}
.task { .task {
async let albumsLoad: () = loadAlbums() async let albumsLoad: () = loadAlbums()
async let detailLoad: () = loadArtistDetail() async let detailLoad: () = loadArtistDetail()
@@ -138,6 +143,9 @@ struct ArtistDetailView: View {
.clipShape(Circle()) .clipShape(Circle())
.shadow(color: .black.opacity(0.5), radius: 20, y: 10) .shadow(color: .black.opacity(0.5), radius: 20, y: 10)
HStack(spacing: 6) {
ProviderBadge(uri: artist.uri, imageProvider: artist.imageProvider)
if !albums.isEmpty { if !albums.isEmpty {
Text("\(albums.count) albums") Text("\(albums.count) albums")
.font(.subheadline) .font(.subheadline)
@@ -146,6 +154,7 @@ struct ArtistDetailView: View {
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
} }
} }
}
.padding(.top) .padding(.top)
} }
@@ -213,6 +213,15 @@ struct ArtistGridItem: View {
} }
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.clipShape(Circle()) .clipShape(Circle())
.overlay(alignment: .bottomTrailing) {
if service.libraryManager.isFavorite(uri: artist.uri) {
Image(systemName: "heart.fill")
.font(.system(size: 12))
.foregroundStyle(.red)
.padding(4)
}
}
Text(artist.name) Text(artist.name)
.font(.caption) .font(.caption)
@@ -25,6 +25,12 @@ struct PlaylistDetailView: View {
.sorted { $0.name < $1.name } .sorted { $0.name < $1.name }
} }
private var nowPlayingURIs: Set<String> {
Set(service.playerManager.playerQueues.values.compactMap {
$0.currentItem?.mediaItem?.uri
})
}
var body: some View { var body: some View {
ZStack { ZStack {
// Blurred Background with Ken Burns Effect // Blurred Background with Ken Burns Effect
@@ -268,7 +274,7 @@ struct PlaylistDetailView: View {
private var trackList: some View { private var trackList: some View {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
TrackRow(track: track, trackNumber: index + 1, useLightTheme: true) TrackRow(track: track, trackNumber: index + 1, useLightTheme: true, isPlaying: nowPlayingURIs.contains(track.uri))
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
if players.count == 1 { if players.count == 1 {
+117 -3
View File
@@ -17,20 +17,30 @@ struct PlayerNowPlayingView: View {
@State private var isMuted = false @State private var isMuted = false
@State private var preMuteVolume: Double = 50 @State private var preMuteVolume: Double = 50
@State private var showQueue = false @State private var showQueue = false
@State private var displayedElapsed: Double = 0
@State private var progressTimer: Timer?
// Auto-tracks live updates via @Observable // Auto-tracks live updates via @Observable
private var player: MAPlayer? { private var player: MAPlayer? {
service.playerManager.players[playerId] service.playerManager.players[playerId]
} }
private var playerQueue: MAPlayerQueue? {
service.playerManager.playerQueues[playerId]
}
private var currentItem: MAQueueItem? { private var currentItem: MAQueueItem? {
service.playerManager.playerQueues[playerId]?.currentItem playerQueue?.currentItem
} }
private var mediaItem: MAMediaItem? { private var mediaItem: MAMediaItem? {
currentItem?.mediaItem currentItem?.mediaItem
} }
private var trackDuration: Double {
Double(currentItem?.duration ?? 0)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Header // Header
@@ -47,6 +57,11 @@ struct PlayerNowPlayingView: View {
Spacer(minLength: 0) Spacer(minLength: 0)
// Progress bar (only in player mode, not queue)
if !showQueue {
progressView
}
// Transport + volume (always visible) // Transport + volume (always visible)
controlsView controlsView
} }
@@ -80,6 +95,25 @@ struct PlayerNowPlayingView: View {
} }
.onAppear { .onAppear {
localVolume = Double(player?.volume ?? 50) localVolume = Double(player?.volume ?? 50)
syncElapsedTime()
startProgressTimer()
}
.onDisappear {
progressTimer?.invalidate()
progressTimer = nil
}
.onChange(of: playerQueue?.elapsedTime) {
syncElapsedTime()
}
.onChange(of: player?.state) {
// Restart timer when play state changes
syncElapsedTime()
if player?.state == .playing {
startProgressTimer()
} else {
progressTimer?.invalidate()
progressTimer = nil
}
} }
.presentationDetents([.large]) .presentationDetents([.large])
.presentationDragIndicator(.hidden) .presentationDragIndicator(.hidden)
@@ -129,12 +163,12 @@ struct PlayerNowPlayingView: View {
Image(systemName: "list.bullet") Image(systemName: "list.bullet")
.font(.title3) .font(.title3)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundStyle(showQueue ? .accent : .primary) .foregroundStyle(showQueue ? Color.accentColor : .primary)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
if let uri = mediaItem?.uri { if !showQueue, let uri = mediaItem?.uri {
FavoriteButton(uri: uri, size: 22) FavoriteButton(uri: uri, size: 22)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
} }
@@ -281,6 +315,86 @@ struct PlayerNowPlayingView: View {
} }
} }
// MARK: - Progress View
@ViewBuilder
private var progressView: some View {
let duration = trackDuration
let progress = duration > 0 ? min(displayedElapsed / duration, 1.0) : 0
VStack(spacing: 4) {
// Progress bar
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(.primary.opacity(0.15))
.frame(height: 4)
Capsule()
.fill(.primary)
.frame(width: geo.size.width * progress, height: 4)
}
}
.frame(height: 4)
// Time labels
HStack {
Text(formatTime(displayedElapsed))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
Spacer()
Text("-\(formatTime(max(0, duration - displayedElapsed)))")
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.padding(.horizontal, 32)
.padding(.bottom, 4)
}
// MARK: - Progress Helpers
private func syncElapsedTime() {
guard let queue = playerQueue,
let elapsed = queue.elapsedTime else {
displayedElapsed = 0
return
}
if player?.state == .playing,
let lastUpdated = queue.elapsedTimeLastUpdated {
let serverNow = Date().timeIntervalSince1970
let delta = serverNow - lastUpdated
displayedElapsed = elapsed + max(0, delta)
} else {
displayedElapsed = elapsed
}
}
private func startProgressTimer() {
progressTimer?.invalidate()
guard player?.state == .playing else { return }
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
Task { @MainActor in
guard player?.state == .playing else { return }
displayedElapsed += 0.5
// Clamp to duration
if trackDuration > 0 {
displayedElapsed = min(displayedElapsed, trackDuration)
}
}
}
}
private func formatTime(_ seconds: Double) -> String {
let totalSeconds = Int(max(0, seconds))
let m = totalSeconds / 60
let s = totalSeconds % 60
return String(format: "%d:%02d", m, s)
}
// MARK: - Volume Helpers // MARK: - Volume Helpers
private func adjustVolume(by delta: Int) { private func adjustVolume(by delta: Int) {