feat(playlist): client-side sort and search, with the control in the toolbar - #430
Open
senshinya wants to merge 3 commits into
Open
feat(playlist): client-side sort and search, with the control in the toolbar#430senshinya wants to merge 3 commits into
senshinya wants to merge 3 commits into
Conversation
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>
This was referenced Aug 13, 2026
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'ViewThatFitsre-measuring whenever it shares anHStackwith a flexible sibling under theScrollView, and listed four options for a maintainer. This PR implements option 3 — move the control into the toolbar / outside theScrollView— which sidesteps the re-measure entirely rather than working around it.It also fixes the second finding in that issue: #375 noted
displayedTrackRowswas 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
AI Tool: Claude Code
Type of Change
Related Issues
Changes Made
Sort
PlaylistSortKey(Original Order / Title / Artist / Duration / Album) +PlaylistSortOrder; re-selecting the active key toggles direction.PlaylistTrackListPresenter, a pure enum with no state or networking, so the ordering rules are testable in isolation.↑ 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
.searchable(placement: .toolbar), so it stays reachable and clearable deep into a long playlist and sits beside the sort control it works with.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
displayedTrackswas a computed property, so SwiftUI re-ran the full filter and sort on every observed change — each paged append and everyloadingStatetransition. It is now cached and invalidated fromplaylistDetail'sdidSet, so no load path can miss it.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
ForEachkeyed 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 offSong.rowIdentity— the per-occurrence identityPlaylistPlaybackActionsalready computed, now shared so display and playback agree on what "the same track" means..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.ContentUnavailableView.searchfor 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
Localizable.xcstringsand all 17.lprojmirrors.Testing
swift test --skip KasetUITests) — 2984 tests, 234 suites, greenScripts/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 survivesLocalizationCatalogParityTestsgreen after the catalog changesNew 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,displayedTracksas 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
swiftlint --strict && swiftformat .— 0 violations in 621 files; formatting cleandocs/api-discovery.md, ADR-0033)Screenshots
Sorted by artist. The toolbar control reads
↑ Artistrather than a stateless glyph, so the active key and direction are legible without opening the menu. Rows carryArtist • Album, so an album sort orders on something the row actually shows.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.
The new Playlists setting, off by default.
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
HStackwith theViewThatFitsbutton 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.
KasetUITestsruns in CI and will exercise this change — Liked Music renders throughPlaylistDetailView. I checked its assertions against what moved: those tests match on the "Liked Music" title and an identifier-matched play button, and don't querytextFields, so the new toolbar search field doesn't disturb them. Theapp.textFields.firstMatchinSidebarUITestsruns on the global Search page, which has no playlist detail view.No accessibility identifiers on the new controls.
AccessibilityIdentifiers.PlaylistDetailexists, 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
Songcarries 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.