From 6b740bd1b3bb306c388d352d676c6b5df53df2fd Mon Sep 17 00:00:00 2001 From: tsibog Date: Thu, 13 Aug 2026 14:55:59 +0000 Subject: [PATCH 1/5] feat(ui): make artist names clickable in track list rows Artist names in playlist track rows are now NavigationLinks when the artist has a navigable ID, instead of plain Text. Non-navigable artists remain plain text. Reuses the existing HoverUnderlineNavigationLink pattern with customizable font and foreground style. Closes #435 --- Sources/Kaset/Views/PlaylistDetailView.swift | 61 ++++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/Sources/Kaset/Views/PlaylistDetailView.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index 2b0f64e91..5fed89d3a 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,12 @@ struct PlaylistDetailView: View { return fallbackArtist } + private func trackArtists(for track: Song, fallbackAuthor: String?) -> [Artist]? { + let artists = self.uniqueArtists(from: track.artists) + guard !artists.isEmpty else { return nil } + return artists + } + private func uniqueArtists(from artists: [Artist]) -> [Artist] { var seen = Set() var uniqueArtists: [Artist] = [] @@ -530,6 +537,7 @@ struct PlaylistDetailView: View { fallbackArtist: String?, fallbackAlbum: Album? ) { let intent = self.playerService.beginMusicPlaybackIntent() + self.playerService.sourcePlaylistId = self.viewModel.playlistDetail?.id Task { @MainActor in let willDeferLoad = self.viewModel.hasMore let loadGeneration = await self.playerService.playQueue( @@ -748,6 +756,7 @@ private struct PlaylistTrackRow: View { let index: Int let isAlbum: Bool let subtitle: String? + let artists: [Artist]? let allowsLikeActions: Bool let onPlay: () -> Void @ViewBuilder let menu: () -> Menu @@ -792,10 +801,14 @@ private struct PlaylistTrackRow: View { } } if let subtitle = self.subtitle { - Text(subtitle) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(1) + if self.hasNavigableArtists { + self.artistLinksView + } else { + Text(subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } .frame(maxWidth: .infinity, alignment: .leading) @@ -817,6 +830,40 @@ private struct PlaylistTrackRow: View { .onHover { hovering in self.isHovered = hovering } .contextMenu { self.menu() } } + + private var hasNavigableArtists: Bool { + self.artists?.contains(where: { $0.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) + } + } + } + } } // MARK: - HoverUnderlineNavigationLink @@ -824,14 +871,18 @@ private struct PlaylistTrackRow: View { private 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(.subheadline) + .font(self.font) + .foregroundStyle(self.foregroundStyle) .underline(self.isHovering) + .lineLimit(1) } .buttonStyle(.plain) .onHover { hovering in From b87451f56364e116339386cc9f86178c9a227332 Mon Sep 17 00:00:00 2001 From: tsibog Date: Thu, 13 Aug 2026 15:21:50 +0000 Subject: [PATCH 2/5] fix: resolve build, lint, and format failures - Remove sourcePlaylistId assignment (belongs in sidebar-highlight PR, not this one). Fixes build failure: PlayerService has no sourcePlaylistId member on this branch. - Extract PlaylistTrackRow to its own file to bring PlaylistDetailView.swift under the 900-line SwiftLint limit (934 -> 790 lines). - Extract HoverUnderlineNavigationLink to its own file (was private, now internal for cross-file reuse). - Mark unused fallbackAuthor parameter with _ (SwiftFormat unusedArguments rule). - Use keyPath syntax for contains(where:) (SwiftFormat preferKeyPath rule). --- .../Views/HoverUnderlineNavigationLink.swift | 29 ++++ Sources/Kaset/Views/PlaylistDetailView.swift | 146 +----------------- Sources/Kaset/Views/PlaylistTrackRow.swift | 125 +++++++++++++++ 3 files changed, 155 insertions(+), 145 deletions(-) create mode 100644 Sources/Kaset/Views/HoverUnderlineNavigationLink.swift create mode 100644 Sources/Kaset/Views/PlaylistTrackRow.swift diff --git a/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift b/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift new file mode 100644 index 000000000..4c5e2bcb7 --- /dev/null +++ b/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift @@ -0,0 +1,29 @@ +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) + } + .buttonStyle(.plain) + .onHover { hovering in + self.isHovering = hovering + } + } +} diff --git a/Sources/Kaset/Views/PlaylistDetailView.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index 5fed89d3a..793de6cc2 100644 --- a/Sources/Kaset/Views/PlaylistDetailView.swift +++ b/Sources/Kaset/Views/PlaylistDetailView.swift @@ -337,7 +337,7 @@ struct PlaylistDetailView: View { return fallbackArtist } - private func trackArtists(for track: Song, fallbackAuthor: String?) -> [Artist]? { + private func trackArtists(for track: Song, fallbackAuthor _: String?) -> [Artist]? { let artists = self.uniqueArtists(from: track.artists) guard !artists.isEmpty else { return nil } return artists @@ -537,7 +537,6 @@ struct PlaylistDetailView: View { fallbackArtist: String?, fallbackAlbum: Album? ) { let intent = self.playerService.beginMusicPlaybackIntent() - self.playerService.sourcePlaylistId = self.viewModel.playlistDetail?.id Task { @MainActor in let willDeferLoad = self.viewModel.hasMore let loadGeneration = await self.playerService.playQueue( @@ -748,149 +747,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 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: { $0.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) - } - } - } - } -} - -// MARK: - HoverUnderlineNavigationLink - -private 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) - } - .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..3fff87c4d --- /dev/null +++ b/Sources/Kaset/Views/PlaylistTrackRow.swift @@ -0,0 +1,125 @@ +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) + } + } + } + } +} From 2f0b28372a4ca6afe72d160140c2100bcc5c97ec Mon Sep 17 00:00:00 2001 From: tsibog Date: Sat, 15 Aug 2026 23:31:31 +0200 Subject: [PATCH 3/5] fix(ui): make whole artist name clickable in track rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row Button won the hit test on the right portion of the link, so clicks past ~2/3 of the name played the track instead of navigating. Measured: link frame matched the text width exactly (72.5pt), so sizing was fine — the label's composite hit shape was the problem. contentShape now closes the label modifier chain, vertical padding widens the target band, and pointerStyle(.link) restores the missing hand cursor. --- Sources/Kaset/Views/HoverUnderlineNavigationLink.swift | 3 +++ Sources/Kaset/Views/PlaylistTrackRow.swift | 2 ++ 2 files changed, 5 insertions(+) diff --git a/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift b/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift index 4c5e2bcb7..f56aa6dbd 100644 --- a/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift +++ b/Sources/Kaset/Views/HoverUnderlineNavigationLink.swift @@ -20,8 +20,11 @@ struct HoverUnderlineNavigationLink: View { .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/PlaylistTrackRow.swift b/Sources/Kaset/Views/PlaylistTrackRow.swift index 3fff87c4d..fc3322d7c 100644 --- a/Sources/Kaset/Views/PlaylistTrackRow.swift +++ b/Sources/Kaset/Views/PlaylistTrackRow.swift @@ -120,6 +120,8 @@ struct PlaylistTrackRow: View { .lineLimit(1) } } + + Spacer(minLength: 0) } } } From 692cbd7f2a6129025df8f4b4b6dd409b5a3f063b Mon Sep 17 00:00:00 2001 From: tsibog Date: Sat, 15 Aug 2026 23:31:36 +0200 Subject: [PATCH 4/5] feat(ui): link album track rows to the header artist Album track entries carry no per-track artist objects, so trackArtists returned nil and every album row rendered plain text while the header link worked. Fall back to the detail author when it is navigable and its name matches the displayed subtitle. --- Sources/Kaset/Views/PlaylistDetailView.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Sources/Kaset/Views/PlaylistDetailView.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index 793de6cc2..c54dbe9c4 100644 --- a/Sources/Kaset/Views/PlaylistDetailView.swift +++ b/Sources/Kaset/Views/PlaylistDetailView.swift @@ -337,10 +337,19 @@ struct PlaylistDetailView: View { return fallbackArtist } - private func trackArtists(for track: Song, fallbackAuthor _: String?) -> [Artist]? { + private func trackArtists(for track: Song, fallbackAuthor: String?) -> [Artist]? { let artists = self.uniqueArtists(from: track.artists) - guard !artists.isEmpty else { return nil } - return 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] { From e2f7457da19431db9def49186bc26bd3c158c809 Mon Sep 17 00:00:00 2001 From: tsibog Date: Sun, 16 Aug 2026 01:55:14 +0200 Subject: [PATCH 5/5] test(history): gate refresh on continuation to fix macos-15 flake loadMoreBlockedWhileRefreshRewindsHistoryCursor simulated an in-flight refresh with a 100ms getHistory delay, then polled every 25ms before calling loadMore. On loaded runners the poll returned after refresh had already completed, so the !isRefreshingHistory guard no longer blocked loadMore: 3 continuation calls instead of 1, "Older" appended. Replace the delay with an explicit continuation gate on the mock, matching the existing shouldWaitForRemoveSongFromPlaylistResponse pattern, so refresh cannot finish before loadMore is attempted. --- Tests/KasetTests/Helpers/MockYTMusicClient.swift | 13 +++++++++++++ Tests/KasetTests/HistoryViewModelTests.swift | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) 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)