From 64ba5e8fab46e0f170c1042858cce66cf161578d Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Feb 2026 08:14:18 +0000 Subject: [PATCH] feat: add Artists section to Library Adds subscribed/followed artists to the Library view, closing GitHub issue #60. - Add `getLibraryArtists()` to `YTMusicClientProtocol` and `YTMusicClient` - Fetches from `FEmusic_library_corpus_track_artists` (the filter chip endpoint from `FEmusic_library_landing`, no extra params required) - Extend `PlaylistParser.LibraryContent` with `artists: [Artist]` field - `parseLibraryContent` now classifies `UC*` browse IDs as artists - Add `parseLibraryArtists(_:)` for the dedicated artists endpoint - Update `LibraryViewModel` to fetch artists in parallel with library content - Add `artists: [Artist]`, `libraryArtistIds: Set` - Add `addToLibrary(artist:)`, `removeFromLibrary(artistId:)`, `isInLibrary(artistId:)` - Update `LibraryView` with: - New `artists` case in `LibraryFilter` enum (between Playlists and Podcasts) - `artistCard()` view (circular thumbnail, artist name, Artist subtitle) - `navigationDestination(for: Artist.self)` to ArtistDetailView - Updated empty-state messages for the artists filter - Update `MockYTMusicClient` and `MockUITestYTMusicClient` with `getLibraryArtists()` - Add comprehensive artist library tests to `LibraryViewModelTests` Co-Authored-By: Claude Opus 4.6 --- .../API/MockUITestYTMusicClient.swift | 7 +- .../Services/API/Parsers/PlaylistParser.swift | 97 +++++++++++++++++-- Core/Services/API/YTMusicClient.swift | 16 +++ Core/Services/Protocols.swift | 3 + Core/ViewModels/LibraryViewModel.swift | 39 +++++++- .../Helpers/MockYTMusicClient.swift | 10 +- Tests/KasetTests/LibraryViewModelTests.swift | 91 ++++++++++++++++- Views/macOS/LibraryView.swift | 67 ++++++++++++- 8 files changed, 311 insertions(+), 19 deletions(-) diff --git a/Core/Services/API/MockUITestYTMusicClient.swift b/Core/Services/API/MockUITestYTMusicClient.swift index 2574d8f..095c184 100644 --- a/Core/Services/API/MockUITestYTMusicClient.swift +++ b/Core/Services/API/MockUITestYTMusicClient.swift @@ -247,7 +247,12 @@ final class MockUITestYTMusicClient: YTMusicClientProtocol { func getLibraryContent() async throws -> PlaylistParser.LibraryContent { try? await Task.sleep(for: .milliseconds(100)) - return PlaylistParser.LibraryContent(playlists: self.playlists, podcastShows: []) + return PlaylistParser.LibraryContent(playlists: self.playlists, podcastShows: [], artists: []) + } + + func getLibraryArtists() async throws -> [Artist] { + try? await Task.sleep(for: .milliseconds(100)) + return [] } func getLikedSongs() async throws -> LikedSongsResponse { diff --git a/Core/Services/API/Parsers/PlaylistParser.swift b/Core/Services/API/Parsers/PlaylistParser.swift index f0e420b..b89aabd 100644 --- a/Core/Services/API/Parsers/PlaylistParser.swift +++ b/Core/Services/API/Parsers/PlaylistParser.swift @@ -21,22 +21,24 @@ enum PlaylistParser { self.parseLibraryContent(data).playlists } - /// Result type for library content parsing containing both playlists and podcast shows. + /// Result type for library content parsing containing playlists, podcast shows, and artists. struct LibraryContent { let playlists: [Playlist] let podcastShows: [PodcastShow] + let artists: [Artist] } - /// Parses library content from browse response, returning both playlists and podcast shows. + /// Parses library content from browse response, returning playlists, podcast shows, and artists. static func parseLibraryContent(_ data: [String: Any]) -> LibraryContent { var playlists: [Playlist] = [] var podcastShows: [PodcastShow] = [] + var artists: [Artist] = [] // Navigate to contents guard let contents = data["contents"] as? [String: Any], let singleColumnBrowseResults = contents["singleColumnBrowseResultsRenderer"] as? [String: Any] else { - return LibraryContent(playlists: [], podcastShows: []) + return LibraryContent(playlists: [], podcastShows: [], artists: []) } guard let tabs = singleColumnBrowseResults["tabs"] as? [[String: Any]], @@ -46,7 +48,7 @@ enum PlaylistParser { let sectionListRenderer = tabContent["sectionListRenderer"] as? [String: Any], let sectionContents = sectionListRenderer["contents"] as? [[String: Any]] else { - return LibraryContent(playlists: [], podcastShows: []) + return LibraryContent(playlists: [], podcastShows: [], artists: []) } for sectionData in sectionContents { @@ -59,7 +61,8 @@ enum PlaylistParser { Self.parseLibraryItem( twoRowRenderer, playlists: &playlists, - podcastShows: &podcastShows + podcastShows: &podcastShows, + artists: &artists ) } } @@ -78,7 +81,8 @@ enum PlaylistParser { Self.parseLibraryItemFromResponsive( responsiveRenderer, playlists: &playlists, - podcastShows: &podcastShows + podcastShows: &podcastShows, + artists: &artists ) } } @@ -95,21 +99,83 @@ enum PlaylistParser { Self.parseLibraryItemFromResponsive( responsiveRenderer, playlists: &playlists, - podcastShows: &podcastShows + podcastShows: &podcastShows, + artists: &artists ) } } } } - return LibraryContent(playlists: playlists, podcastShows: podcastShows) + return LibraryContent(playlists: playlists, podcastShows: podcastShows, artists: artists) + } + + /// Parses subscribed artists from a library artists browse response. + /// Used by getLibraryArtists() which browses FEmusic_library_corpus_track_artists. + static func parseLibraryArtists(_ data: [String: Any]) -> [Artist] { + var artists: [Artist] = [] + + guard let contents = data["contents"] as? [String: Any], + let singleColumnBrowseResults = contents["singleColumnBrowseResultsRenderer"] as? [String: Any], + let tabs = singleColumnBrowseResults["tabs"] as? [[String: Any]], + let firstTab = tabs.first, + let tabRenderer = firstTab["tabRenderer"] as? [String: Any], + let tabContent = tabRenderer["content"] as? [String: Any], + let sectionListRenderer = tabContent["sectionListRenderer"] as? [String: Any], + let sectionContents = sectionListRenderer["contents"] as? [[String: Any]] + else { + return [] + } + + for sectionData in sectionContents { + // Try gridRenderer (common for artist chips view) + if let gridRenderer = sectionData["gridRenderer"] as? [String: Any], + let items = gridRenderer["items"] as? [[String: Any]] + { + for itemData in items { + if let twoRowRenderer = itemData["musicTwoRowItemRenderer"] as? [String: Any], + let navigationEndpoint = twoRowRenderer["navigationEndpoint"] as? [String: Any], + let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any], + let browseId = browseEndpoint["browseId"] as? String, + browseId.hasPrefix("UC") + { + let title = ParsingHelpers.extractTitle(from: twoRowRenderer) ?? "Unknown" + let thumbnails = ParsingHelpers.extractThumbnails(from: twoRowRenderer) + let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) } + artists.append(Artist(id: browseId, name: title, thumbnailURL: thumbnailURL)) + } + } + } + + // Try musicShelfRenderer (responsive list format) + if let shelfRenderer = sectionData["musicShelfRenderer"] as? [String: Any], + let shelfContents = shelfRenderer["contents"] as? [[String: Any]] + { + for shelfItem in shelfContents { + if let responsiveRenderer = shelfItem["musicResponsiveListItemRenderer"] as? [String: Any], + let navigationEndpoint = responsiveRenderer["navigationEndpoint"] as? [String: Any], + let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any], + let browseId = browseEndpoint["browseId"] as? String, + browseId.hasPrefix("UC") + { + let title = ParsingHelpers.extractTitleFromFlexColumns(responsiveRenderer) ?? "Unknown" + let thumbnails = ParsingHelpers.extractThumbnails(from: responsiveRenderer) + let thumbnailURL = thumbnails.last.flatMap { URL(string: $0) } + artists.append(Artist(id: browseId, name: title, thumbnailURL: thumbnailURL)) + } + } + } + } + + return artists } /// Parses a library item from twoRowRenderer, adding to the appropriate array. private static func parseLibraryItem( _ data: [String: Any], playlists: inout [Playlist], - podcastShows: inout [PodcastShow] + podcastShows: inout [PodcastShow], + artists: inout [Artist] ) { guard let navigationEndpoint = data["navigationEndpoint"] as? [String: Any], let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any], @@ -138,6 +204,11 @@ enum PlaylistParser { ) podcastShows.append(show) Self.logger.info("parseLibraryItem: Added podcast show: \(title)") + } else if browseId.hasPrefix("UC") { + // Artist (UC prefix = YouTube channel ID) + let artist = Artist(id: browseId, name: title, thumbnailURL: thumbnailURL) + artists.append(artist) + Self.logger.info("parseLibraryItem: Added artist: \(title)") } else if browseId.hasPrefix("VL") || browseId.hasPrefix("PL") || browseId.hasPrefix("RDCLAK") { // Playlist (VL prefix for saved playlists, PL for playlist IDs, RDCLAK for radio playlists) let playlist = Playlist( @@ -157,7 +228,8 @@ enum PlaylistParser { private static func parseLibraryItemFromResponsive( _ data: [String: Any], playlists: inout [Playlist], - podcastShows: inout [PodcastShow] + podcastShows: inout [PodcastShow], + artists: inout [Artist] ) { guard let navigationEndpoint = data["navigationEndpoint"] as? [String: Any], let browseEndpoint = navigationEndpoint["browseEndpoint"] as? [String: Any], @@ -186,6 +258,11 @@ enum PlaylistParser { ) podcastShows.append(show) Self.logger.info("parseLibraryItemFromResponsive: Added podcast show: \(title)") + } else if browseId.hasPrefix("UC") { + // Artist (UC prefix = YouTube channel ID) + let artist = Artist(id: browseId, name: title, thumbnailURL: thumbnailURL) + artists.append(artist) + Self.logger.info("parseLibraryItemFromResponsive: Added artist: \(title)") } else if browseId.hasPrefix("VL") || browseId.hasPrefix("PL") { // Playlist let playlist = Playlist( diff --git a/Core/Services/API/YTMusicClient.swift b/Core/Services/API/YTMusicClient.swift index 5912d5e..0f849c7 100644 --- a/Core/Services/API/YTMusicClient.swift +++ b/Core/Services/API/YTMusicClient.swift @@ -567,6 +567,22 @@ final class YTMusicClient: YTMusicClientProtocol { return PlaylistParser.parseLibraryContent(data) } + /// Fetches the user's subscribed artists from the library. + /// Uses FEmusic_library_corpus_track_artists which is the artists filter chip + /// from FEmusic_library_landing and does not require additional params. + func getLibraryArtists() async throws -> [Artist] { + self.logger.info("Fetching library artists") + + let body: [String: Any] = [ + "browseId": "FEmusic_library_corpus_track_artists", + ] + + let data = try await request("browse", body: body, ttl: APICache.TTL.library) + let artists = PlaylistParser.parseLibraryArtists(data) + self.logger.info("Parsed \(artists.count) library artists") + return artists + } + // MARK: - Liked Songs with Pagination /// Continuation token for liked songs pagination. diff --git a/Core/Services/Protocols.swift b/Core/Services/Protocols.swift index 5f0abc8..4f5b6dc 100644 --- a/Core/Services/Protocols.swift +++ b/Core/Services/Protocols.swift @@ -146,6 +146,9 @@ protocol YTMusicClientProtocol: Sendable { /// Fetches the user's library content including playlists and podcast shows. func getLibraryContent() async throws -> PlaylistParser.LibraryContent + /// Fetches the user's subscribed artists from the library. + func getLibraryArtists() async throws -> [Artist] + /// Fetches the user's liked songs with pagination support. func getLikedSongs() async throws -> LikedSongsResponse diff --git a/Core/ViewModels/LibraryViewModel.swift b/Core/ViewModels/LibraryViewModel.swift index 24e3de4..f98d7bc 100644 --- a/Core/ViewModels/LibraryViewModel.swift +++ b/Core/ViewModels/LibraryViewModel.swift @@ -15,12 +15,18 @@ final class LibraryViewModel { /// User's subscribed podcast shows. private(set) var podcastShows: [PodcastShow] = [] + /// User's subscribed artists. + private(set) var artists: [Artist] = [] + /// Set of playlist IDs that are in the user's library (for quick lookup). private(set) var libraryPlaylistIds: Set = [] /// Set of podcast show IDs that are in the user's library (for quick lookup). private(set) var libraryPodcastIds: Set = [] + /// Set of artist IDs that are in the user's library (for quick lookup). + private(set) var libraryArtistIds: Set = [] + /// Selected playlist detail. private(set) var selectedPlaylistDetail: PlaylistDetail? @@ -50,6 +56,11 @@ final class LibraryViewModel { self.libraryPodcastIds.contains(podcastId) } + /// Checks if an artist is in the user's library. + func isInLibrary(artistId: String) -> Bool { + self.libraryArtistIds.contains(artistId) + } + /// Adds a playlist ID to the library set (called after successful add to library). func addToLibrarySet(playlistId: String) { self.libraryPlaylistIds.insert(playlistId) @@ -93,7 +104,23 @@ final class LibraryViewModel { self.libraryPodcastIds.remove(podcastId) } - /// Loads library content (playlists and podcasts). + /// Adds an artist to the library (called after successful subscription). + /// Updates both the ID set and the artists array for immediate UI update. + func addToLibrary(artist: Artist) { + self.libraryArtistIds.insert(artist.id) + if !self.artists.contains(where: { $0.id == artist.id }) { + self.artists.insert(artist, at: 0) + } + } + + /// Removes an artist from the library (called after successful unsubscribe). + /// Updates both the ID set and the artists array for immediate UI update. + func removeFromLibrary(artistId: String) { + self.libraryArtistIds.remove(artistId) + self.artists.removeAll { $0.id == artistId } + } + + /// Loads library content (playlists, podcasts, and artists). func load() async { guard self.loadingState != .loading else { return } @@ -101,14 +128,19 @@ final class LibraryViewModel { self.logger.info("Loading library content") do { - let content = try await client.getLibraryContent() + async let contentTask = client.getLibraryContent() + async let artistsTask = client.getLibraryArtists() + + let (content, fetchedArtists) = try await (contentTask, artistsTask) self.playlists = content.playlists self.podcastShows = content.podcastShows + self.artists = fetchedArtists // Update the sets for quick lookup self.libraryPlaylistIds = Set(content.playlists.map(\.id)) self.libraryPodcastIds = Set(content.podcastShows.map(\.id)) + self.libraryArtistIds = Set(fetchedArtists.map(\.id)) self.loadingState = .loaded - self.logger.info("Loaded \(content.playlists.count) playlists and \(content.podcastShows.count) podcasts") + self.logger.info("Loaded \(content.playlists.count) playlists, \(content.podcastShows.count) podcasts, and \(fetchedArtists.count) artists") } catch is CancellationError { // Task was cancelled (e.g., user navigated away) — reset to idle so it can retry self.logger.debug("Library load cancelled") @@ -152,6 +184,7 @@ final class LibraryViewModel { func refresh() async { self.playlists = [] self.podcastShows = [] + self.artists = [] await self.load() } } diff --git a/Tests/KasetTests/Helpers/MockYTMusicClient.swift b/Tests/KasetTests/Helpers/MockYTMusicClient.swift index c43fcbc..b5e997c 100644 --- a/Tests/KasetTests/Helpers/MockYTMusicClient.swift +++ b/Tests/KasetTests/Helpers/MockYTMusicClient.swift @@ -22,6 +22,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { var searchContinuationResponses: [SearchResponse] = [] var searchSuggestions: [SearchSuggestion] = [] var libraryPlaylists: [Playlist] = [] + var libraryArtists: [Artist] = [] var likedSongs: [Song] = [] var likedSongsContinuationSongs: [[Song]] = [] var playlistDetails: [String: PlaylistDetail] = [:] @@ -107,6 +108,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { private(set) var getSearchSuggestionsCalled = false private(set) var getSearchSuggestionsQueries: [String] = [] private(set) var getLibraryPlaylistsCalled = false + private(set) var getLibraryArtistsCalled = false private(set) var getLikedSongsCalled = false private(set) var getLikedSongsContinuationCalled = false private(set) var getLikedSongsContinuationCallCount = 0 @@ -426,7 +428,13 @@ final class MockYTMusicClient: YTMusicClientProtocol { func getLibraryContent() async throws -> PlaylistParser.LibraryContent { self.getLibraryPlaylistsCalled = true if let error = shouldThrowError { throw error } - return PlaylistParser.LibraryContent(playlists: self.libraryPlaylists, podcastShows: []) + return PlaylistParser.LibraryContent(playlists: self.libraryPlaylists, podcastShows: [], artists: []) + } + + func getLibraryArtists() async throws -> [Artist] { + self.getLibraryArtistsCalled = true + if let error = shouldThrowError { throw error } + return self.libraryArtists } func getLikedSongs() async throws -> LikedSongsResponse { diff --git a/Tests/KasetTests/LibraryViewModelTests.swift b/Tests/KasetTests/LibraryViewModelTests.swift index 51a73d1..41b089d 100644 --- a/Tests/KasetTests/LibraryViewModelTests.swift +++ b/Tests/KasetTests/LibraryViewModelTests.swift @@ -18,6 +18,7 @@ struct LibraryViewModelTests { func initialState() { #expect(self.viewModel.loadingState == .idle) #expect(self.viewModel.playlists.isEmpty) + #expect(self.viewModel.artists.isEmpty) #expect(self.viewModel.selectedPlaylistDetail == nil) } @@ -94,7 +95,95 @@ struct LibraryViewModelTests { #expect(self.viewModel.playlists.count == 2) } - // MARK: - Podcast Library Tests + // MARK: - Artist Library Tests + + @Test("Load success sets artists") + func loadSuccessSetsArtists() async { + self.mockClient.libraryArtists = [ + TestFixtures.makeArtist(id: "UC1", name: "Artist One"), + TestFixtures.makeArtist(id: "UC2", name: "Artist Two"), + ] + + await self.viewModel.load() + + #expect(self.mockClient.getLibraryArtistsCalled == true) + #expect(self.viewModel.loadingState == .loaded) + #expect(self.viewModel.artists.count == 2) + #expect(self.viewModel.artists[0].name == "Artist One") + #expect(self.viewModel.libraryArtistIds.contains("UC1") == true) + #expect(self.viewModel.libraryArtistIds.contains("UC2") == true) + } + + @Test("addToLibrary inserts artist at beginning and updates ID set") + func addToLibraryInsertsArtist() { + let artist = TestFixtures.makeArtist(id: "UC100", name: "New Artist") + + self.viewModel.addToLibrary(artist: artist) + + #expect(self.viewModel.libraryArtistIds.contains("UC100") == true) + #expect(self.viewModel.artists.first?.id == "UC100") + #expect(self.viewModel.artists.first?.name == "New Artist") + } + + @Test("addToLibrary does not duplicate existing artist") + func addToLibraryNoDuplicateArtist() { + let artist = TestFixtures.makeArtist(id: "UC100", name: "New Artist") + + self.viewModel.addToLibrary(artist: artist) + self.viewModel.addToLibrary(artist: artist) + + #expect(self.viewModel.artists.count(where: { $0.id == "UC100" }) == 1) + #expect(self.viewModel.libraryArtistIds.count == 1) + } + + @Test("removeFromLibrary removes artist from both set and array") + func removeFromLibraryRemovesArtist() { + let artist = TestFixtures.makeArtist(id: "UC100", name: "New Artist") + self.viewModel.addToLibrary(artist: artist) + #expect(self.viewModel.artists.count == 1) + #expect(self.viewModel.libraryArtistIds.count == 1) + + self.viewModel.removeFromLibrary(artistId: "UC100") + + #expect(self.viewModel.artists.isEmpty) + #expect(self.viewModel.libraryArtistIds.isEmpty) + } + + @Test("removeFromLibrary handles non-existent artist gracefully") + func removeFromLibraryNonExistentArtist() { + self.viewModel.removeFromLibrary(artistId: "UCnonexistent") + + #expect(self.viewModel.artists.isEmpty) + #expect(self.viewModel.libraryArtistIds.isEmpty) + } + + @Test("isInLibrary returns true for added artist") + func isInLibraryForAddedArtist() { + let artist = TestFixtures.makeArtist(id: "UC100", name: "New Artist") + self.viewModel.addToLibrary(artist: artist) + + #expect(self.viewModel.isInLibrary(artistId: "UC100") == true) + } + + @Test("isInLibrary returns false for non-added artist") + func isInLibraryForNonAddedArtist() { + #expect(self.viewModel.isInLibrary(artistId: "UC100") == false) + } + + @Test("Refresh clears artists and reloads") + func refreshClearsArtistsAndReloads() async { + self.mockClient.libraryArtists = [TestFixtures.makeArtist(id: "UC1")] + await self.viewModel.load() + #expect(self.viewModel.artists.count == 1) + + self.mockClient.libraryArtists = [ + TestFixtures.makeArtist(id: "UC2"), + TestFixtures.makeArtist(id: "UC3"), + ] + await self.viewModel.refresh() + + #expect(self.viewModel.artists.count == 2) + } @Test("addToLibrary inserts podcast at beginning and updates ID set") func addToLibraryInsertsPodcast() { diff --git a/Views/macOS/LibraryView.swift b/Views/macOS/LibraryView.swift index 946df5f..90ebbb5 100644 --- a/Views/macOS/LibraryView.swift +++ b/Views/macOS/LibraryView.swift @@ -6,6 +6,7 @@ import SwiftUI enum LibraryFilter: String, CaseIterable, Identifiable { case all = "All" case playlists = "Playlists" + case artists = "Artists" case podcasts = "Podcasts" var id: String { @@ -16,6 +17,7 @@ enum LibraryFilter: String, CaseIterable, Identifiable { switch self { case .all: "square.grid.2x2" case .playlists: "music.note.list" + case .artists: "music.microphone" case .podcasts: "mic.fill" } } @@ -69,6 +71,15 @@ struct LibraryView: View { ) ) } + .navigationDestination(for: Artist.self) { artist in + ArtistDetailView( + artist: artist, + viewModel: ArtistDetailViewModel( + artist: artist, + client: self.viewModel.client + ) + ) + } .navigationDestination(for: PodcastShow.self) { [libraryViewModelEnv] show in PodcastShowView(show: show, client: self.viewModel.client) .environment(libraryViewModelEnv) @@ -139,11 +150,14 @@ struct LibraryView: View { switch self.selectedFilter { case .all: - // Interleave playlists and podcasts for variety + // Interleave playlists, artists, and podcasts for variety items = self.viewModel.playlists.map { .playlist($0) } + + self.viewModel.artists.map { .artist($0) } + self.viewModel.podcastShows.map { .podcast($0) } case .playlists: items = self.viewModel.playlists.map { .playlist($0) } + case .artists: + items = self.viewModel.artists.map { .artist($0) } case .podcasts: items = self.viewModel.podcastShows.map { .podcast($0) } } @@ -163,6 +177,8 @@ struct LibraryView: View { switch item { case let .playlist(playlist): self.playlistCard(playlist) + case let .artist(artist): + self.artistCard(artist) case let .podcast(show): self.podcastCard(show) } @@ -197,6 +213,8 @@ struct LibraryView: View { "Your library is empty" case .playlists: "No playlists yet" + case .artists: + "No artists yet" case .podcasts: "No podcasts yet" } @@ -205,9 +223,11 @@ struct LibraryView: View { private var emptyStateMessage: String { switch self.selectedFilter { case .all: - "Save playlists and subscribe to podcasts on YouTube Music to see them here." + "Save playlists, follow artists, and subscribe to podcasts on YouTube Music to see them here." case .playlists: "Create or save playlists on YouTube Music to see them here." + case .artists: + "Follow artists on YouTube Music to see them here." case .podcasts: "Subscribe to podcasts on YouTube Music to see them here." } @@ -253,6 +273,44 @@ struct LibraryView: View { .buttonStyle(.plain) } + private func artistCard(_ artist: Artist) -> some View { + Button { + self.navigationPath.append(artist) + } label: { + VStack(alignment: .leading, spacing: 8) { + // Thumbnail (circular for artists) + CachedAsyncImage(url: artist.thumbnailURL?.highQualityThumbnailURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Circle() + .fill(.quaternary) + .overlay { + Image(systemName: "music.microphone") + .font(.largeTitle) + .foregroundStyle(.secondary) + } + } + .frame(width: 160, height: 160) + .clipShape(.circle) + + // Name + Text(artist.name) + .font(.system(size: 13, weight: .medium)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(width: 160, alignment: .leading) + + // Type label + Text("Artist") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } + private func podcastCard(_ show: PodcastShow) -> some View { Button { self.navigationPath.append(show) @@ -300,15 +358,18 @@ struct LibraryView: View { // MARK: - LibraryItem -/// Represents a library item that can be either a playlist or a podcast show. +/// Represents a library item that can be a playlist, an artist, or a podcast show. enum LibraryItem: Identifiable { case playlist(Playlist) + case artist(Artist) case podcast(PodcastShow) var id: String { switch self { case let .playlist(playlist): "playlist-\(playlist.id)" + case let .artist(artist): + "artist-\(artist.id)" case let .podcast(show): "podcast-\(show.id)" }