Skip to content

feat(playlist): client-side sort and search, with the control in the toolbar - #430

Open
senshinya wants to merge 3 commits into
sozercan:mainfrom
senshinya:feature/playlist-sort-search
Open

feat(playlist): client-side sort and search, with the control in the toolbar#430
senshinya wants to merge 3 commits into
sozercan:mainfrom
senshinya:feature/playlist-sort-search

Conversation

@senshinya

@senshinya senshinya commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Description

Adds client-side sort and search to the playlist detail page, with the sort control in the toolbar rather than the header action row.

Closes #375. That issue bisected the scroll/hover jank from #369 to headerButtons' ViewThatFits re-measuring whenever it shares an HStack with a flexible sibling under the ScrollView, and listed four options for a maintainer. This PR implements option 3 — move the control into the toolbar / outside the ScrollView — which sidesteps the re-measure entirely rather than working around it.

It also fixes the second finding in that issue: #375 noted displayedTrackRows was a computed getter that re-sorted on every read and was "worth memoizing into a stored property regardless of the placement decision." The same problem existed here and is fixed (details below).

AI Prompt (Optional)

🤖 AI Prompt Used
Add client-side sort and search to the playlist detail page.

Then: review the branch for optimizable points, and fix what you find.
Measure before fixing anything about timing or lifecycle — don't guess.

AI Tool: Claude Code

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)
  • 🎨 UI/UX improvement
  • 🐛 Bug fix (non-breaking change that fixes an issue)

Related Issues

Changes Made

Sort

  • PlaylistSortKey (Original Order / Title / Artist / Duration / Album) + PlaylistSortOrder; re-selecting the active key toggles direction.
  • Sorting and filtering live in PlaylistTrackListPresenter, a pure enum with no state or networking, so the ordering rules are testable in isolation.
  • The toolbar control names the active key and direction (↑ Artist) instead of being a stateless glyph — an icon-only menu leaves you unable to tell what the list is sorted by, or which way, without opening it.

Search

  • Native .searchable(placement: .toolbar), so it stays reachable and clearable deep into a long playlist and sits beside the sort control it works with.
  • Matches title, artist, and album, case- and diacritic-insensitive.
  • Playlist rows now show the album (Artist • Album), so sorting by album no longer orders on a field the row never displays. Album pages omit it — it's already in the header.

Playback follows what you see

The header Play / Play Next / Add to Queue actions act on the displayed list, matching what a row tap already queues. With a sort active that means the whole playlist in the order shown; with a search active it means the matches, which is what the screen is offering.

Playing while pages are still draining needed care. Queueing the visible snapshot and topping it up afterwards — the existing deferred-load path — appends from the raw playlist, so a search would have played its matches and then continued into the tracks it excluded, and a sort would have stopped applying partway down the queue. When a sort or search is active, playback now waits for the drain (already running, with progress on screen) and queues exactly the displayed list, relocating the tapped track by identity. Unsorted, unfiltered playback keeps the existing play-immediately-and-top-up behavior, which is unaffected.

Why client-side

YouTube Music's server-side playlist sort only reorders the returned window; its continuation tokens carry no sort state, so paginated results revert to the default order. Documented in docs/api-discovery.md. Activating a sort or search therefore drains the remaining pages (single-flight, coalescing with any in-flight drain) so ordering covers every track rather than the loaded window. Typing debounces that drain by 300 ms so a word doesn't start a round of continuation requests per keystroke.

Performance

  • displayedTracks was a computed property, so SwiftUI re-ran the full filter and sort on every observed change — each paged append and every loadingState transition. It is now cached and invalidated from playlistDetail's didSet, so no load path can miss it.
  • The comparator worked off artistsDisplay, which re-joins the artist array on every access — O(n log n) rebuilds of the same strings per sort. Keys are now decorated once per track.

Correctness

  • The track ForEach keyed rows by position, which is not identity once the list reorders: SwiftUI recycled row-local state (hover, in-flight like) onto the wrong track. Rows now key off Song.rowIdentity — the per-occurrence identity PlaylistPlaybackActions already computed, now shared so display and playback agree on what "the same track" means.
  • Sort/search reset was wired to .onDisappear, which also fires when the screen is pushed over. A timestamped trace caught it firing with an active sort immediately before the pushed destination appeared — so sorting a playlist, tapping through to an artist, and coming back silently dropped the sort. The reset now runs once per presentation, guarded by @State, which survives a push/pop pair.
  • A mid-drain search no longer shows ContentUnavailableView.search for a track that simply hasn't paged in yet. Drain progress moved above the list, where it's visible while rows are still reordering underneath.

Settings

  • New opt-in "Load All Songs When Opening a Playlist" (off by default) under a Playlists section, for people who would rather pay the requests up front than page as they scroll.
  • Localization added to Localizable.xcstrings and all 17 .lproj mirrors.

Testing

  • Unit tests pass (swift test --skip KasetUITests) — 2984 tests, 234 suites, green
  • Manual testing performed — packaged build via Scripts/compile_and_run.sh; sorted by each key in both directions, searched during an active drain, played from sorted and filtered lists via both the header button and row taps, round-tripped to an artist page and back to confirm the sort survives
  • UI tested on macOS 26+
  • LocalizationCatalogParityTests green after the catalog changes

New tests: PlaylistTrackListPresenterTests (sort per key, direction, nils-last, stability, filtering, album matching, multi-artist sort keys, row identity, sort-is-a-permutation in both directions, filter preserves sorted order), PlaylistDetailViewModelTests (sort/filter over loaded tracks, paged-in tracks folded into an active sort, displayedTracks as the single playback source, the complete filtered set after a pre-playback drain with the tapped track still locatable, drain debounce), PlaylistAutoLoadSettingTests.

The permutation test exists because the header Play button now queues displayedTracks: a sort that dropped or duplicated a track would silently truncate or corrupt the play queue rather than just look wrong.

Checklist

  • My code follows the project's style guidelines
  • I have run swiftlint --strict && swiftformat . — 0 violations in 621 files; formatting clean
  • I have added tests that prove my fix/feature works
  • New and existing unit tests pass locally
  • I have updated documentation if needed (docs/api-discovery.md, ADR-0033)
  • I have checked for any performance implications
  • My changes generate no new warnings

Screenshots

Sorted by artist. The toolbar control reads ↑ Artist rather than a stateless glyph, so the active key and direction are legible without opening the menu. Rows carry Artist • Album, so an album sort orders on something the row actually shows.

1-sort-by-artist

Searching, with the sort still applied. Both controls compose: results stay in artist order. Two of these matched on the album rather than the title — "Tems • Love Is A Kingdom" and "Olivia Rodrigo • you seem pretty sad for a girl so in love" — which is the album matching this PR adds.

2-search-matches-album

The new Playlists setting, off by default.

3-settings-playlists

Additional Notes

Overlap with #390. That PR adds search within a playlist and landed first; this one covers sort and search together plus the memoization from #375. Happy to cut the search half out of this PR and rebase on top of #390 if that's the preferred order — the sort work and the caching fix are independent of it. One thing worth a maintainer's eye either way: #390 places its search control in the header action row, which is the row #375 bisected the jank to ("any flexible layout that shares an HStack with the ViewThatFits button group re-triggers its measurement under scroll"). I haven't measured #390 myself, so this is a pointer, not a claim.

Also overlapping: #351 adds live search to Liked Music and touches PlaylistDetailViewModel / PlaylistDetailView, so there will be textual conflicts there regardless of merge order.

UI test suite not run locally. KasetUITests runs in CI and will exercise this change — Liked Music renders through PlaylistDetailView. I checked its assertions against what moved: those tests match on the "Liked Music" title and an identifier-matched play button, and don't query textFields, so the new toolbar search field doesn't disturb them. The app.textFields.firstMatch in SidebarUITests runs on the global Search page, which has no playlist detail view.

No accessibility identifiers on the new controls. AccessibilityIdentifiers.PlaylistDetail exists, and the sort menu and search field could take entries there. Left out to keep this diff to the feature; happy to add them if you want UI coverage for sort/search.

Not included, deliberately: the row number renumbers under sort, because Song carries no track-number field — the column has always been a row ordinal, and renumbering it in display order is what Spotify does too. Adding a real track number would mean an API investigation, which felt out of scope here.

senshinya and others added 3 commits August 10, 2026 20:56
Add a toolbar sort menu (original / title / artist / duration / album, with
ascending/descending toggle) and an inline search field to the playlist detail
page. Sorting and searching run client-side over the fully-loaded track set via
a pure PlaylistTrackListPresenter; the first sort/search interaction drains all
remaining pages through the existing single-flight loadAllRemaining(). Server
sort params exist but cannot be paginated, so they are not used (documented in
api-discovery.md).

Add a default-off "Load all songs when opening a playlist" setting that eagerly
drains every page on open. Sort/search state is ephemeral per open and reset on
close (the persistent Liked Music view model is reused, so reset explicitly).

Localized in all 17 shipped locales. Extracted the artist-formatting and
sort/search helpers into PlaylistDetailView extensions to stay within file/type
length limits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sort and search on the playlist detail page had a few problems that only
show up on large playlists.

displayedTracks was a computed property, so SwiftUI re-ran the full filter
and sort on every observed change — each paged append and every loadingState
transition. Draining a large playlist meant tens of full re-sorts on the main
actor, each rebuilding artistsDisplay inside the comparator O(n log n) times.
It is now cached, invalidated from playlistDetail's didSet so no load path can
miss it, and the comparator works off keys decorated once per track.

The track ForEach keyed rows by position, which is not identity once the list
reorders: SwiftUI recycled row-local state (hover, in-flight like) onto the
wrong track. Rows now key off playlistSetVideoId, which is per-occurrence.

Sort/search reset was wired to onDisappear, which also fires when the screen is
pushed over. A trace confirmed it firing with an active sort right before the
pushed destination appeared, silently dropping the sort on a round trip to an
artist page. The reset now runs once per presentation, guarded by @State.

Search moved from the scrolling content into the toolbar via .searchable, so
it stays reachable and clearable deep into a long playlist and sits beside the
sort control. Clicking the page body now resigns its focus — track rows are
Buttons and don't take first responder on macOS, so focus had nowhere to go.

The sort toolbar button now names the active key and direction instead of being
a stateless icon, and playlist rows show the album, so sorting by album no
longer orders on a field the row never displays. Album is searchable too.

Typing debounces the full-playlist drain rather than starting a fresh round of
continuation requests per keystroke, and a mid-drain search no longer claims
"No Results" for a track that simply hasn't paged in yet. Drain progress moved
above the list, where it is visible while rows are reordering underneath.

Merges the auto-load setting's tooltip into its footer; the request cost is the
reason to leave it off and belongs where it is always visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Playback disagreed with the screen. The header Play / Play Next / Add to Queue
actions built their queue from the raw track list while a row tap used the
displayed one, so sorting a playlist and pressing Play played the original
order, and searching then pressing Play played the whole playlist. All four now
queue the displayed list.

That exposed a second problem. The deferred-load path plays the visible snapshot
immediately and tops the queue up once pagination finishes, appending from the
raw playlist — which would replay the tracks a search had excluded and abandon
the sort partway down the queue. With a sort or search active, playback now
waits for the drain (already running, with progress on screen) and queues
exactly the displayed list, relocating the tapped track by identity. Unsorted,
unfiltered playback keeps the play-immediately path unchanged.

Song.rowIdentity was a weaker re-implementation of the occurrence identity
PlaylistPlaybackActions already computed: it lacked the empty-string guard, so a
blank playlistSetVideoId collapsed every such row onto one ForEach id; it keyed
off `id` where the established helper used `videoId`; and it dropped the
set:/video: namespacing that keeps a set id from colliding with another track's
video id. Both now share one implementation.

Adds ADR-0033 for the client-side sort/search decision, the toolbar placement
that answers issue sozercan#375, and the playback contract above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@senshinya

Copy link
Copy Markdown
Contributor Author

Friendly ping @sozercan 🙂 — whenever you get a chance. This closes #375 (the toolbar-placement call from the #369 jank investigation); CI's green and it's mergeable. Happy to adjust anything you'd like.

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.

Playlist track sort: header sort-control placement causes scroll/hover jank (ViewThatFits re-measure)

1 participant