Skip to content

feat(ui): make artist names clickable in track list rows - #442

Open
tsibog wants to merge 5 commits into
sozercan:mainfrom
tsibog:feat/clickable-artist-rows
Open

feat(ui): make artist names clickable in track list rows#442
tsibog wants to merge 5 commits into
sozercan:mainfrom
tsibog:feat/clickable-artist-rows

Conversation

@tsibog

@tsibog tsibog commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

Artist names in playlist track rows are currently plain Text — clicking the row only plays the track. This PR makes artist names individually clickable NavigationLinks when the artist has a navigable ID, while keeping the row's play-on-click behavior intact. Non-navigable artists remain plain text.

AI Prompt (Optional)

🤖 AI Prompt Used
N/A - Manual implementation

AI Tool: Claude

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to change)
  • 📚 Documentation update
  • 🎨 UI/UX improvement
  • ♻️ Refactoring (no functional changes)
  • 🧪 Test update
  • 🔧 Build/CI configuration

Related Issues

Closes #435

Changes Made

  • PlaylistDetailView.swift:
    • Extended HoverUnderlineNavigationLink with font and foregroundStyle parameters so it can match the track row subtitle styling (12pt, .secondary)
    • Added artists: [Artist]? property to PlaylistTrackRow
    • Replaced the subtitle Text with artistLinksView when navigable artists are available; falls back to plain Text(subtitle) otherwise
    • Added hasNavigableArtists computed property and artistLinksView that renders each artist as a HoverUnderlineNavigationLink (or plain Text for non-navigable artists), separated by ", "
    • Added trackArtists(for:fallbackAuthor:) helper that returns the unique Artist array for linking
    • Updated the trackRow call site to pass the artists parameter
    • trackArtists(for:fallbackAuthor:) now falls back to the detail author when a track carries no artist objects of its own — see "Album track rows" below
  • HoverUnderlineNavigationLink.swift: contentShape(.rect) now closes the label modifier chain, vertical padding widens the hit band, and pointerStyle(.link) provides the hand cursor
  • PlaylistTrackRow.swift: trailing Spacer(minLength: 0) in the artist HStack so leftover row width is absorbed by the spacer rather than distributed to the links

Screencast

Screen.Recording.2026-08-15.at.23.29.51-compressed.mp4

Testing

  • Unit tests pass (swift test --skip KasetUITests) — 2956 tests in 232 suites
  • Manual testing performed
  • UI tested on macOS 26+

Verified in a packaged build: playlist rows and album rows, hover underline, click-through to the artist page, and click-elsewhere-in-row still plays the track.

Checklist

  • My code follows the project's style guidelines
  • I have run swiftlint --strict && swiftformat .
  • I have added tests that prove my fix/feature works
  • New and existing unit tests pass locally
  • I have updated documentation if needed
  • I have checked for any performance implications
  • My changes generate no new warnings

No automated test was added: the defects here are SwiftUI hit-testing and pointer-region behavior, which have no test seam in this codebase — NSHostingView flattens the hierarchy, so hitTest cannot distinguish the link from the row button.

Additional Notes

Hit-testing fix

The first version of this PR had a real defect: only part of each artist name was clickable. Clicks past roughly two-thirds of the name fell through to the row and played the track instead of navigating, and the link cursor never appeared at all.

Instrumenting the real view (geometry logged from a packaged build) ruled out the obvious explanation — the link frame already matched the rendered text exactly:

TEXT 'Dub Element' frame=(390.0, 355.0, 72.5, 15.0) intrinsicWidth=72.16
LINK 'Dub Element' frame=(390.0, 355.0, 72.5, 15.0)

onHover also fired across the full width (true at localX 2.46, false at 72.39). So sizing and hover tracking were both correct; what differed was click hit-testing — the row Button won on the right portion. Confirmed by tap probes: clicks at localX 27.0 and 48.7 activated the link, a click at ~68.5 triggered the row's play action.

The fix makes contentShape(.rect) the last modifier in the label chain (nothing after it to disturb the composite hit shape), adds a little vertical padding to widen the target band, and adds pointerStyle(.link).

This also corrects an assumption in the original version of this description, which claimed the nested NavigationLink "intercepts taps on its own area". It does not do so reliably — that is the bug.

Album track rows

Album track entries returned by YouTube carry no per-track artist objects, so trackArtists returned nil and every album row rendered plain text even though the album header's artist link worked. trackArtists now falls back to the detail author, guarded twice: only when that author has a navigable ID, and only when its name matches the subtitle the row is actually displaying. Albums whose author is an Artist.inline(…) placeholder degrade to plain text exactly as before, rather than rendering a dead link.

This is scope beyond the original "playlist track rows" framing, so calling it out explicitly.

Known follow-up (not in this PR)

PlaylistTrackRow wraps the whole row in Button(action: onPlay) and puts other interactive controls (artist links, LikeButton) inside that button's label. Nested controls in a Button label make SwiftUI arbitrate who wins a click, and that arbitration is the root cause of the hit-testing bug above; the fix here tunes the label's hit shape rather than removing the ambiguity.

A more robust structure inverts the layering so hit priority is z-order rather than arbitration:

HStack {  artist links, LikeButton  }
    .background(
        Button(action: onPlay) { Color.clear.contentShape(.rect) }
            .buttonStyle(.interactiveRow(cornerRadius: 6))
    )

Foreground controls always win where they are; the background catches everything else. Worth doing as a shared row container rather than inline, since .interactiveRow is a shared button style and this row shape likely repeats in album/search/queue views — which may carry the same latent bug. Left out of this PR to keep the diff reviewable.

tsibog added 2 commits August 13, 2026 14:55
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 sozercan#435
- 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).
@tsibog

tsibog commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

tsibog added 2 commits August 15, 2026 23:31
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.
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.
@tsibog

tsibog commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Suggested follow-up, deliberately not in this PR.

The hit-testing bug fixed here has a structural root cause: PlaylistTrackRow wraps the entire row in Button(action: onPlay) and then places other interactive controls — the artist links and LikeButton — inside that button's label. Nested controls in a Button label force SwiftUI to arbitrate who wins a click, and that arbitration is what failed: measured geometry showed the link frame was already exactly the text width (72.5pt) and onHover fired across all of it, yet clicks past ~2/3 of the name fell through to the row.

The fix in this PR tunes the label's hit shape. It works and it's verified, but it doesn't remove the ambiguity.

A more robust structure inverts the layering so hit priority is z-order instead of arbitration:

HStack {  artist links, LikeButton  }
    .background(
        Button(action: onPlay) { Color.clear.contentShape(.rect) }
            .buttonStyle(.interactiveRow(cornerRadius: 6))
    )

Foreground controls always win where they are; the background catches everything else.

Two reasons to do it separately rather than here: it's a bigger diff than this PR's reviewers are expecting, and .interactiveRow may render hover/pressed states differently in a background layer, so it needs its own visual pass. It's probably also worth extracting as a shared row container — .interactiveRow is a shared button style and this row shape likely repeats in the album, search, and queue views, which may carry the same latent bug.

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.
@tsibog

tsibog commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

CI: fixed the macos-15 unit test failure (unrelated to this PR's diff)

The only failing check was macOS Unit Tests (macos-15), on a test this PR does not touch:

✘ "Load more is blocked while refresh rewinds the history cursor"
  HistoryViewModelTests.swift:192  (getHistoryContinuationCallCount → 3) == 1
  HistoryViewModelTests.swift:193  sections ["Today","Yesterday","Older"] == ["Today","Yesterday"]

Root cause

It's a real race in the test, not just noise. The test simulated an in-flight refresh with a wall-clock delay, then polled on a coarser interval than the delay itself:

self.mockClient.getHistoryDelay = .milliseconds(100)   // refresh self-completes after 100ms
let refreshTask = Task { await self.viewModel.refresh() }
await self.waitForHistoryRefresh { ... getHistoryCallCount == 2 }  // polls every 25ms
await self.viewModel.loadMore()                        // must run while refresh is still in flight

loadMore() is guarded by !isRefreshingHistory (HistoryViewModel.swift:147). On a loaded runner the poll loop can return after the 100 ms sleep has already elapsed, so refresh is finished and the guard no longer blocks. loadMore() then runs the skip-preserved-page continuation plus a real one → 3 continuation calls and "Older" appended, which is exactly the pair of assertions above.

macos-26 runners are fast enough to usually win the race, so only macos-15 trips it.

Fix (e2f7457, test-only)

Removed the timing dependency by gating getHistory() on an explicit continuation, following the mock's existing shouldWaitForRemoveSongFromPlaylistResponse precedent:

  • MockYTMusicClient: added shouldWaitForGetHistoryResponse, a continuation queue, resumeNextGetHistoryResponse(), and a reset entry; getHistory() suspends instead of sleeping.
  • HistoryViewModelTests: set the gate → wait for the call → loadMore()then resume, so refresh cannot complete early regardless of runner speed.

Verification

  • 25/25 consecutive runs of the previously flaky test pass.
  • Two full unit runs with the fix: 2956 tests in 232 suites passed (23.0s, 21.3s), matching a clean pre-change baseline of the same 2956/0.
  • swiftformat clean (0 files changed), swiftlint --strict clean.

No production code touched; the feature diff in this PR is unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Make artist and album names clickable in track list views

1 participant