Queue, Favorites, Providers, Now playing
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user