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
+26 -6
View File
@@ -184,20 +184,22 @@ struct MAMediaItem: Codable, Identifiable, Hashable {
let album: MAAlbum?
let metadata: MediaItemMetadata?
let duration: Int?
let favorite: Bool
var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey {
case uri, name, duration, artists, album, metadata
case uri, name, duration, artists, album, metadata, favorite
case mediaType = "media_type"
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.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) }
}
@@ -205,6 +207,7 @@ struct MAMediaItem: Codable, Identifiable, Hashable {
let c = try decoder.container(keyedBy: CodingKeys.self)
uri = try c.decode(String.self, forKey: .uri)
name = try c.decode(String.self, forKey: .name)
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
// Media type is critical - decode it first
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(duration, forKey: .duration)
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 sortName: String?
let musicbrainzId: String?
let favorite: Bool
var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey {
case uri, name, metadata
case uri, name, metadata, favorite
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) {
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.metadata = imageUrl.map { MediaItemMetadata(images: [MediaItemImage(type: "thumb", path: $0, provider: imageProvider, remotelyAccessible: nil)], cacheChecksum: nil) }
self.sortName = sortName; self.musicbrainzId = musicbrainzId
self.favorite = favorite
}
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)
favorite = (try? c.decode(Bool.self, forKey: .favorite)) ?? false
sortName = try? c.decodeIfPresent(String.self, forKey: .sortName)
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(musicbrainzId, forKey: .musicbrainzId)
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 metadata: MediaItemMetadata?
let year: Int?
let favorite: Bool
var id: String { uri }
var imageUrl: String? { metadata?.thumbImage?.path }
var imageProvider: String? { metadata?.thumbImage?.provider }
enum CodingKeys: String, CodingKey {
case uri, name, artists, metadata, year
case uri, name, artists, metadata, year, favorite
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.favorite = favorite
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)
uri = try c.decode(String.self, forKey: .uri)
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)
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(year, forKey: .year)
try c.encodeIfPresent(metadata, forKey: .metadata)
try c.encode(favorite, forKey: .favorite)
}
}
@@ -407,11 +419,17 @@ struct MAPlayerQueue: Codable {
let queueId: String
let currentItem: MAQueueItem?
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 {
case queueId = "queue_id"
case currentItem = "current_item"
case currentIndex = "current_index"
case elapsedTime = "elapsed_time"
case elapsedTimeLastUpdated = "elapsed_time_last_updated"
}
init(from decoder: Decoder) throws {
@@ -419,6 +437,8 @@ struct MAPlayerQueue: Codable {
queueId = try c.decode(String.self, forKey: .queueId)
currentItem = try? c.decodeIfPresent(MAQueueItem.self, forKey: .currentItem)
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 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)
private(set) var lastArtistsRefresh: Date?
private(set) var lastAlbumsRefresh: Date?
@@ -42,7 +46,7 @@ final class MALibraryManager {
// MARK: - Disk Cache
/// 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 = {
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")
}
// 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
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
@@ -152,10 +160,13 @@ final class MALibraryManager {
if refresh {
artists = newArtists
artistsOffset = newArtists.count
// Reset and repopulate artist favorites on refresh
for a in artists where a.favorite { favoriteURIs.insert(a.uri) }
} else {
artists.append(contentsOf: newArtists)
artistsOffset += newArtists.count
}
for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) }
hasMoreArtists = newArtists.count >= pageSize
if refresh || artistsOffset <= pageSize {
@@ -199,10 +210,12 @@ final class MALibraryManager {
if refresh {
albums = newAlbums
albumsOffset = newAlbums.count
for a in albums where a.favorite { favoriteURIs.insert(a.uri) }
} else {
albums.append(contentsOf: newAlbums)
albumsOffset += newAlbums.count
}
for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) }
hasMoreAlbums = newAlbums.count >= pageSize
if refresh || albumsOffset <= pageSize {
@@ -255,6 +268,53 @@ final class MALibraryManager {
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
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
@@ -119,8 +119,25 @@ final class MAPlayerManager {
}
private func handleQueueItemsUpdated(_ event: MAEvent) async {
// Similar to queue_updated
// Update queue state (current item, current index)
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
@@ -235,6 +252,13 @@ final class MAPlayerManager {
}
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 {
guard let service else {
@@ -192,6 +192,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
func playIndex(playerId: String, index: Int) async throws {
@@ -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
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
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
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
logger.debug("🔍 Searching for '\(query)'")
@@ -12,8 +12,15 @@ struct EnhancedPlayerPickerView: View {
@Environment(MAService.self) private var service
let players: [MAPlayer]
let title: String
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)
private var syncedMemberIds: Set<String> {
Set(players.flatMap { $0.groupChilds })
@@ -52,7 +59,7 @@ struct EnhancedPlayerPickerView: View {
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.navigationTitle("Play on...")
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -16,6 +16,7 @@ struct AlbumDetailView: View {
@State private var errorMessage: String?
@State private var showError = false
@State private var showPlayerPicker = false
@State private var showEnqueuePicker = false
@State private var selectedPlayer: MAPlayer?
@State private var kenBurnsScale: CGFloat = 1.0
@State private var completeAlbum: MAAlbum?
@@ -28,6 +29,13 @@ struct AlbumDetailView: View {
.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 {
ZStack {
// Blurred Background with Ken Burns Effect
@@ -39,8 +47,8 @@ struct AlbumDetailView: View {
// Album Header
albumHeader
// Play Button
playButton
// Action Buttons
actionButtons
Divider()
.background(Color.white.opacity(0.3))
@@ -90,6 +98,11 @@ struct AlbumDetailView: View {
.navigationTitle(album.name)
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
FavoriteButton(uri: album.uri, size: 22, showInLight: true)
}
}
.task {
async let tracksLoad: () = loadTracks()
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
@@ -191,7 +213,9 @@ struct AlbumDetailView: View {
.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 {
Text(String(year))
.font(.subheadline)
@@ -213,38 +237,67 @@ struct AlbumDetailView: View {
.padding(.top)
}
// MARK: - Play Button
// MARK: - Action Buttons
@ViewBuilder
private var playButton: some View {
Button {
if players.count == 1 {
selectedPlayer = players.first
Task {
await playAlbum(on: players.first!)
private var actionButtons: some View {
HStack(spacing: 12) {
// Play Album
Button {
if players.count == 1 {
selectedPlayer = players.first
Task { await playAlbum(on: players.first!) }
} else {
showPlayerPicker = true
}
} else {
showPlayerPicker = true
}
} label: {
Label("Play Album", systemImage: "play.fill")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(
LinearGradient(
colors: [Color.white.opacity(0.3), Color.white.opacity(0.2)],
startPoint: .top,
endPoint: .bottom
} label: {
Label("Play", systemImage: "play.fill")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.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)
.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)
}
// 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)
.disabled(tracks.isEmpty || players.isEmpty)
@@ -257,7 +310,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, useLightTheme: true)
TrackRow(track: track, trackNumber: index + 1, useLightTheme: true, isPlaying: nowPlayingURIs.contains(track.uri))
.contentShape(Rectangle())
.onTapGesture {
if players.count == 1 {
@@ -371,6 +424,18 @@ struct AlbumDetailView: View {
showError = true
}
}
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 {
do {
@@ -388,17 +453,28 @@ struct AlbumDetailView: View {
// MARK: - Track Row
struct TrackRow: View {
@Environment(MAService.self) private var service
let track: MAMediaItem
let trackNumber: Int
var useLightTheme: Bool = false
var isPlaying: Bool = false
var body: some View {
HStack(spacing: 12) {
// Track Number
Text("\(trackNumber)")
.font(.subheadline)
.foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary)
.frame(width: 30, alignment: .trailing)
// Track Number / Now Playing indicator
Group {
if isPlaying {
Image(systemName: "waveform")
.font(.caption)
.foregroundStyle(.green)
.symbolEffect(.variableColor.iterative, isActive: true)
} else {
Text("\(trackNumber)")
.font(.subheadline)
.foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary)
}
}
.frame(width: 30, alignment: .trailing)
// Track Info
VStack(alignment: .leading, spacing: 4) {
@@ -416,6 +492,9 @@ struct TrackRow: View {
}
Spacer()
// Favorite
FavoriteButton(uri: track.uri, size: 16, showInLight: useLightTheme)
// Duration
if let duration = track.duration {
@@ -115,6 +115,15 @@ struct AlbumGridItem: View {
}
.aspectRatio(1, contentMode: .fit)
.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
VStack(alignment: .leading, spacing: 2) {
@@ -56,6 +56,11 @@ struct ArtistDetailView: View {
.navigationTitle(artist.name)
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
FavoriteButton(uri: artist.uri, size: 22, showInLight: true)
}
}
.task {
async let albumsLoad: () = loadAlbums()
async let detailLoad: () = loadArtistDetail()
@@ -138,12 +143,16 @@ struct ArtistDetailView: View {
.clipShape(Circle())
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
if !albums.isEmpty {
Text("\(albums.count) albums")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.white.opacity(0.8))
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
HStack(spacing: 6) {
ProviderBadge(uri: artist.uri, imageProvider: artist.imageProvider)
if !albums.isEmpty {
Text("\(albums.count) albums")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.white.opacity(0.8))
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
}
}
}
.padding(.top)
@@ -213,6 +213,15 @@ struct ArtistGridItem: View {
}
.aspectRatio(1, contentMode: .fit)
.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)
.font(.caption)
@@ -25,6 +25,12 @@ struct PlaylistDetailView: View {
.sorted { $0.name < $1.name }
}
private var nowPlayingURIs: Set<String> {
Set(service.playerManager.playerQueues.values.compactMap {
$0.currentItem?.mediaItem?.uri
})
}
var body: some View {
ZStack {
// Blurred Background with Ken Burns Effect
@@ -268,7 +274,7 @@ struct PlaylistDetailView: 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, useLightTheme: true)
TrackRow(track: track, trackNumber: index + 1, useLightTheme: true, isPlaying: nowPlayingURIs.contains(track.uri))
.contentShape(Rectangle())
.onTapGesture {
if players.count == 1 {