diff --git a/Mobile Music Assistant.xcodeproj/project.pbxproj b/Mobile Music Assistant.xcodeproj/project.pbxproj index eecbdc1..d96dabd 100644 --- a/Mobile Music Assistant.xcodeproj/project.pbxproj +++ b/Mobile Music Assistant.xcodeproj/project.pbxproj @@ -7,10 +7,14 @@ objects = { /* 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerQueueView.swift; sourceTree = ""; }; + 2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsComponentsProviderBadge.swift; sourceTree = ""; }; 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewsPlayerNowPlayingView.swift; sourceTree = ""; }; 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -40,6 +44,8 @@ 26ED92632F759EEA0025419D /* Mobile Music Assistant */, 26ED92622F759EEA0025419D /* Products */, 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */, + 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */, + 2681ED702F8399A9002FB204 /* ViewsComponentsProviderBadge.swift */, ); sourceTree = ""; }; @@ -125,6 +131,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2681ED6F2F8393AC002FB204 /* ViewsPlayerQueueView.swift in Sources */, + 2681ED712F8399A9002FB204 /* ViewsComponentsProviderBadge.swift in Sources */, 26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -272,7 +280,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -308,7 +316,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Mobile Music Assistant/ModelsMAModels.swift b/Mobile Music Assistant/ModelsMAModels.swift index 31c284a..9fe6dcf 100644 --- a/Mobile Music Assistant/ModelsMAModels.swift +++ b/Mobile Music Assistant/ModelsMAModels.swift @@ -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) } } diff --git a/Mobile Music Assistant/ServicesMALibraryManager.swift b/Mobile Music Assistant/ServicesMALibraryManager.swift index 630b40a..e032cd8 100644 --- a/Mobile Music Assistant/ServicesMALibraryManager.swift +++ b/Mobile Music Assistant/ServicesMALibraryManager.swift @@ -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 = [] + // 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] { diff --git a/Mobile Music Assistant/ServicesMAPlayerManager.swift b/Mobile Music Assistant/ServicesMAPlayerManager.swift index cae681d..7785cc2 100644 --- a/Mobile Music Assistant/ServicesMAPlayerManager.swift +++ b/Mobile Music Assistant/ServicesMAPlayerManager.swift @@ -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 { diff --git a/Mobile Music Assistant/ServicesMAService.swift b/Mobile Music Assistant/ServicesMAService.swift index 5107c39..c3a5d3a 100644 --- a/Mobile Music Assistant/ServicesMAService.swift +++ b/Mobile Music Assistant/ServicesMAService.swift @@ -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)'") diff --git a/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift index ded29cc..5eb25fc 100644 --- a/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift +++ b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift @@ -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 { 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) { diff --git a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift index c2e9f8b..4b93190 100644 --- a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift @@ -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 { + 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 { diff --git a/Mobile Music Assistant/ViewsLibraryAlbumsView.swift b/Mobile Music Assistant/ViewsLibraryAlbumsView.swift index 0eea620..f3efc18 100644 --- a/Mobile Music Assistant/ViewsLibraryAlbumsView.swift +++ b/Mobile Music Assistant/ViewsLibraryAlbumsView.swift @@ -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) { diff --git a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift index 5b6218f..f93a461 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift @@ -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) diff --git a/Mobile Music Assistant/ViewsLibraryArtistsView.swift b/Mobile Music Assistant/ViewsLibraryArtistsView.swift index 38c694c..5cf37ec 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistsView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistsView.swift @@ -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) diff --git a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift index 81bab7b..ede92d4 100644 --- a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift @@ -25,6 +25,12 @@ struct PlaylistDetailView: View { .sorted { $0.name < $1.name } } + private var nowPlayingURIs: Set { + 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 { diff --git a/ViewsPlayerNowPlayingView.swift b/ViewsPlayerNowPlayingView.swift index 06af335..70a172e 100644 --- a/ViewsPlayerNowPlayingView.swift +++ b/ViewsPlayerNowPlayingView.swift @@ -17,20 +17,30 @@ struct PlayerNowPlayingView: View { @State private var isMuted = false @State private var preMuteVolume: Double = 50 @State private var showQueue = false + @State private var displayedElapsed: Double = 0 + @State private var progressTimer: Timer? // Auto-tracks live updates via @Observable private var player: MAPlayer? { service.playerManager.players[playerId] } + private var playerQueue: MAPlayerQueue? { + service.playerManager.playerQueues[playerId] + } + private var currentItem: MAQueueItem? { - service.playerManager.playerQueues[playerId]?.currentItem + playerQueue?.currentItem } private var mediaItem: MAMediaItem? { currentItem?.mediaItem } + private var trackDuration: Double { + Double(currentItem?.duration ?? 0) + } + var body: some View { VStack(spacing: 0) { // Header @@ -47,6 +57,11 @@ struct PlayerNowPlayingView: View { Spacer(minLength: 0) + // Progress bar (only in player mode, not queue) + if !showQueue { + progressView + } + // Transport + volume (always visible) controlsView } @@ -80,6 +95,25 @@ struct PlayerNowPlayingView: View { } .onAppear { 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]) .presentationDragIndicator(.hidden) @@ -129,12 +163,12 @@ struct PlayerNowPlayingView: View { Image(systemName: "list.bullet") .font(.title3) .fontWeight(.semibold) - .foregroundStyle(showQueue ? .accent : .primary) + .foregroundStyle(showQueue ? Color.accentColor : .primary) .frame(width: 44, height: 44) .contentShape(Rectangle()) } - if let uri = mediaItem?.uri { + if !showQueue, let uri = mediaItem?.uri { FavoriteButton(uri: uri, size: 22) .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 private func adjustVolume(by delta: Int) {