diff --git a/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift b/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift new file mode 100644 index 000000000..f56aa6dbd --- /dev/null +++ b/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift @@ -0,0 +1,32 @@ +import SwiftUI + +// MARK: - HoverUnderlineNavigationLink + +/// A navigation link that underlines its label on hover. +/// +/// Used for artist names and other inline navigation targets in track rows and headers. +struct HoverUnderlineNavigationLink: View { + let value: Value + let title: String + var font: Font = .subheadline + var foregroundStyle: Color = .secondary + + @State private var isHovering = false + + var body: some View { + NavigationLink(value: self.value) { + Text(self.title) + .font(self.font) + .foregroundStyle(self.foregroundStyle) + .underline(self.isHovering) + .lineLimit(1) + .padding(.vertical, 2) + .contentShape(.rect) + } + .buttonStyle(.plain) + .pointerStyle(.link) + .onHover { hovering in + self.isHovering = hovering + } + } +} diff --git a/Sources/Kaset/Views/PlaylistDetailView.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index 2b0f64e91..c54dbe9c4 100644 --- a/Sources/Kaset/Views/PlaylistDetailView.swift +++ b/Sources/Kaset/Views/PlaylistDetailView.swift @@ -298,6 +298,7 @@ struct PlaylistDetailView: View { index: index, isAlbum: isAlbum, subtitle: self.trackArtistsDisplay(for: track, fallbackAuthor: author), + artists: self.trackArtists(for: track, fallbackAuthor: author), allowsLikeActions: self.hasPersonalAccount, onPlay: { self.playTrackInQueue( @@ -336,6 +337,21 @@ struct PlaylistDetailView: View { return fallbackArtist } + private func trackArtists(for track: Song, fallbackAuthor: String?) -> [Artist]? { + let artists = self.uniqueArtists(from: track.artists) + if !artists.isEmpty { + return artists + } + + guard let fallbackName = self.cleanedArtistName(fallbackAuthor), + let author = self.cleanedArtist(self.viewModel.playlistDetail?.author), + author.hasNavigableId, + author.name == fallbackName + else { return nil } + + return [author] + } + private func uniqueArtists(from artists: [Artist]) -> [Artist] { var seen = Set() var uniqueArtists: [Artist] = [] @@ -740,106 +756,6 @@ struct PlaylistDetailView: View { } } -// MARK: - PlaylistTrackRow - -@available(macOS 26.0, *) -private struct PlaylistTrackRow: View { - let track: Song - let index: Int - let isAlbum: Bool - let subtitle: String? - let allowsLikeActions: Bool - let onPlay: () -> Void - @ViewBuilder let menu: () -> Menu - - @State private var isHovered: Bool = false - @Environment(PlayerService.self) private var playerService - - var body: some View { - let isCurrent = self.playerService.currentTrack?.videoId == self.track.videoId - - Button(action: self.onPlay) { - HStack(spacing: 12) { - Group { - if isCurrent { - NowPlayingIndicator(isPlaying: self.playerService.isPlaying, size: 14) - } else { - Text("\(self.index + 1)") - .font(.system(size: 14)) - .foregroundStyle(.secondary) - } - } - .frame(width: 28, alignment: .trailing) - - if !self.isAlbum { - CachedAsyncImage(url: self.track.thumbnailURL, targetSize: CGSize(width: 40, height: 40)) { image in - image.resizable().aspectRatio(contentMode: .fill) - } placeholder: { - Rectangle().fill(.quaternary) - } - .frame(width: 40, height: 40) - .clipShape(.rect(cornerRadius: 4)) - } - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(self.track.title) - .font(.system(size: 14)) - .foregroundStyle(isCurrent ? .red : .primary) - .lineLimit(1) - if self.track.isExplicit == true { - ExplicitBadge() - } - } - if let subtitle = self.subtitle { - Text(subtitle) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - LikeButton(song: self.track, isRowHovered: self.isHovered, allowsActions: self.allowsLikeActions) - - Text(self.track.durationDisplay) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .frame(width: 45, alignment: .trailing) - } - .padding(.vertical, 8) - .padding(.horizontal, 4) - .contentShape(Rectangle()) - .opacity(self.track.isPlayable ? 1 : 0.5) - } - .buttonStyle(.interactiveRow(cornerRadius: 6)) - .disabled(!self.track.isPlayable) - .onHover { hovering in self.isHovered = hovering } - .contextMenu { self.menu() } - } -} - -// MARK: - HoverUnderlineNavigationLink - -private struct HoverUnderlineNavigationLink: View { - let value: Value - let title: String - - @State private var isHovering = false - - var body: some View { - NavigationLink(value: self.value) { - Text(self.title) - .font(.subheadline) - .underline(self.isHovering) - } - .buttonStyle(.plain) - .onHover { hovering in - self.isHovering = hovering - } - } -} - // MARK: - HeaderArtistLinkLabel private struct HeaderArtistLinkLabel: View { diff --git a/Sources/Kaset/Views/PlaylistTrackRow.swift b/Sources/Kaset/Views/PlaylistTrackRow.swift new file mode 100644 index 000000000..fc3322d7c --- /dev/null +++ b/Sources/Kaset/Views/PlaylistTrackRow.swift @@ -0,0 +1,127 @@ +import SwiftUI + +// MARK: - PlaylistTrackRow + +/// A single track row in a playlist or album detail view. +/// +/// Shows track number/now-playing indicator, thumbnail (for non-album views), +/// title, artist links or subtitle, like button, and duration. +/// Artist names are rendered as clickable navigation links when they have +/// navigable IDs; non-navigable artists fall back to plain text. +@available(macOS 26.0, *) +struct PlaylistTrackRow: View { + let track: Song + let index: Int + let isAlbum: Bool + let subtitle: String? + let artists: [Artist]? + let allowsLikeActions: Bool + let onPlay: () -> Void + @ViewBuilder let menu: () -> Menu + + @State private var isHovered: Bool = false + @Environment(PlayerService.self) private var playerService + + var body: some View { + let isCurrent = self.playerService.currentTrack?.videoId == self.track.videoId + + Button(action: self.onPlay) { + HStack(spacing: 12) { + Group { + if isCurrent { + NowPlayingIndicator(isPlaying: self.playerService.isPlaying, size: 14) + } else { + Text("\(self.index + 1)") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + } + .frame(width: 28, alignment: .trailing) + + if !self.isAlbum { + CachedAsyncImage(url: self.track.thumbnailURL, targetSize: CGSize(width: 40, height: 40)) { image in + image.resizable().aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle().fill(.quaternary) + } + .frame(width: 40, height: 40) + .clipShape(.rect(cornerRadius: 4)) + } + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(self.track.title) + .font(.system(size: 14)) + .foregroundStyle(isCurrent ? .red : .primary) + .lineLimit(1) + if self.track.isExplicit == true { + ExplicitBadge() + } + } + if let subtitle = self.subtitle { + if self.hasNavigableArtists { + self.artistLinksView + } else { + Text(subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + LikeButton(song: self.track, isRowHovered: self.isHovered, allowsActions: self.allowsLikeActions) + + Text(self.track.durationDisplay) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(width: 45, alignment: .trailing) + } + .padding(.vertical, 8) + .padding(.horizontal, 4) + .contentShape(Rectangle()) + .opacity(self.track.isPlayable ? 1 : 0.5) + } + .buttonStyle(.interactiveRow(cornerRadius: 6)) + .disabled(!self.track.isPlayable) + .onHover { hovering in self.isHovered = hovering } + .contextMenu { self.menu() } + } + + private var hasNavigableArtists: Bool { + self.artists?.contains(where: \.hasNavigableId) ?? false + } + + @ViewBuilder + private var artistLinksView: some View { + let artists = self.artists ?? [] + + HStack(spacing: 0) { + ForEach(Array(artists.enumerated()), id: \.offset) { index, artist in + if artist.hasNavigableId { + HoverUnderlineNavigationLink( + value: artist, + title: artist.name, + font: .system(size: 12), + foregroundStyle: .secondary + ) + } else { + Text(artist.name) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if index < artists.count - 1 { + Text(verbatim: ", ") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 0) + } + } +} diff --git a/Tests/KasetTests/Helpers/MockYTMusicClient.swift b/Tests/KasetTests/Helpers/MockYTMusicClient.swift index 09e9ca592..83bcf6a1c 100644 --- a/Tests/KasetTests/Helpers/MockYTMusicClient.swift +++ b/Tests/KasetTests/Helpers/MockYTMusicClient.swift @@ -96,6 +96,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this var editSongLibraryStatusErrors: [(any Error)?] = [] var getSongDelay: Duration? var getHistoryDelay: Duration? + var shouldWaitForGetHistoryResponse = false var getPodcastsDelay: Duration? var getPlaylistDelay: Duration? var getPlaylistError: Error? @@ -302,6 +303,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this private(set) var addSongToPlaylistCalls: [AddSongToPlaylistCall] = [] private(set) var removeSongFromPlaylistCalls: [RemoveSongFromPlaylistCall] = [] private var removeSongFromPlaylistResponseContinuations: [CheckedContinuation] = [] + private var getHistoryResponseContinuations: [CheckedContinuation] = [] private(set) var unsubscribeFromPlaylistCalled = false private(set) var unsubscribeFromPlaylistIds: [String] = [] private(set) var subscribeToArtistCalled = false @@ -472,6 +474,11 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this func getHistory() async throws -> HomeResponse { self.getHistoryCallCount += 1 self._historyContinuationIndex = 0 + if self.shouldWaitForGetHistoryResponse { + await withCheckedContinuation { continuation in + self.getHistoryResponseContinuations.append(continuation) + } + } if let getHistoryDelay { try? await Task.sleep(for: getHistoryDelay) } @@ -1101,6 +1108,11 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this } } + func resumeNextGetHistoryResponse() { + guard !self.getHistoryResponseContinuations.isEmpty else { return } + self.getHistoryResponseContinuations.removeFirst().resume() + } + func resumeNextRemoveSongFromPlaylistResponse() { guard !self.removeSongFromPlaylistResponseContinuations.isEmpty else { return } self.removeSongFromPlaylistResponseContinuations.removeFirst().resume() @@ -1437,6 +1449,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this self.editSongLibraryStatusErrors = [] self.getSongDelay = nil self.getHistoryDelay = nil + self.shouldWaitForGetHistoryResponse = false self.mixQueueDelay = nil self.getRadioQueueDelay = nil self.mixQueueResult = RadioQueueResult(songs: [], continuationToken: nil) diff --git a/Tests/KasetTests/HistoryViewModelTests.swift b/Tests/KasetTests/HistoryViewModelTests.swift index d38ec007b..1fb2b2d89 100644 --- a/Tests/KasetTests/HistoryViewModelTests.swift +++ b/Tests/KasetTests/HistoryViewModelTests.swift @@ -180,13 +180,15 @@ struct HistoryViewModelTests { await self.viewModel.loadMore() #expect(self.viewModel.sections.map(\.title) == ["Today", "Yesterday"]) - self.mockClient.getHistoryDelay = .milliseconds(100) + self.mockClient.shouldWaitForGetHistoryResponse = true let refreshTask = Task { await self.viewModel.refresh() } await self.waitForHistoryRefresh { self.mockClient.getHistoryCallCount == 2 } await self.viewModel.loadMore() + + self.mockClient.resumeNextGetHistoryResponse() _ = await refreshTask.value #expect(self.mockClient.getHistoryContinuationCallCount == 1)