Skip to content

Redesign Play/Shuffle/Search controls for song lists#161

Merged
phanan merged 15 commits into
masterfrom
feat/apple-music-list-header
Mar 29, 2026
Merged

Redesign Play/Shuffle/Search controls for song lists#161
phanan merged 15 commits into
masterfrom
feat/apple-music-list-header

Conversation

@phanan
Copy link
Copy Markdown
Member

@phanan phanan commented Mar 29, 2026

Summary

  • Replace old icon-based controls with Apple Music-style Play and Shuffle pill buttons with a search magnifier button
  • Tapping the search button smoothly expands it into a full-width pill-shaped search field with an X to close
  • Play/Shuffle buttons fade out as the search pill expands, and reappear when search is dismissed
  • Hero background image uses deterministic daily rotation (day % count) instead of random shuffle to prevent flicker during search
  • All Songs screen caches its background to handle pagination growth

Test plan

  • Open any song list screen — verify Play, Shuffle, and search buttons are visible as pills
  • Tap Play — songs play in order
  • Tap Shuffle — songs play shuffled
  • Tap search magnifier — verify it expands into a search field while buttons fade out
  • Type a query — verify filtering works and hero image doesn't change
  • Tap X — verify search closes and buttons reappear
  • Run flutter test

Replace the old icon-based play/shuffle/search controls with:
- Two side-by-side rounded Play and Shuffle buttons (Apple Music style)
- A search field that slides in when scrolling down and hides when
  scrolling back up, using AnimatedCrossFade for smooth transitions

The scroll-aware search behavior is driven by the scroll controller
passed from each screen.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 29, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Wires persistent ScrollController instances into multiple screens and forwards them to PlayableListHeader; refactors PlayableListHeader to accept an optional scrollController, remove search expand/collapse callbacks and icon params, and simplify search UI/state and controller lifecycle.

Changes

Cohort / File(s) Summary
Screen files with scroll controller wiring
lib/ui/screens/album_details.dart, lib/ui/screens/artist_details.dart, lib/ui/screens/downloaded.dart, lib/ui/screens/favorites.dart, lib/ui/screens/genre_details.dart, lib/ui/screens/playlist_details.dart, lib/ui/screens/podcast_details.dart, lib/ui/screens/recently_played.dart
Each screen now creates/uses a persistent ScrollController, attaches it to its CustomScrollView (controller: _scrollController) and forwards it to PlayableListHeader via scrollController: _scrollController.
Songs screen updates
lib/ui/screens/songs.dart
Removed BaseSongListHeader alias and flutter_spinkit usage; SongListHeader now accepts and forwards an optional ScrollController; updated usage to PlayableListContext.allSongs and replaced the prior header instantiation accordingly.
Playable list header core widget
lib/ui/widgets/playable_list_header.dart
Added optional ScrollController? scrollController; removed onSearchExpanded, onSearchCollapsed, playIcon, shuffleIcon; replaced previous search-expand UI with a simplified searching state and an AnimatedSwitcher-based swap between controls and search field; added _searchController lifecycle handling and simplified open/close search logic.

Sequence Diagram

sequenceDiagram
    participant User
    participant Screen as "Screen\n(CustomScrollView)"
    participant Controller as "ScrollController"
    participant Header as "PlayableListHeader"
    participant Search as "Search Field"

    User->>Screen: scroll / interact
    Screen->>Controller: update position
    Controller->>Header: shared scroll updates
    User->>Header: open search / tap buttons
    Header->>Search: show / hide / clear search field
    Search->>User: input / cancel
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐇 I hopped upon the scrolling track,
I nudged the header, then stepped back,
When search expands I blink and cheer,
When lists glide smooth the path is clear,
A little rabbit celebrates the scroll.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Redesign Play/Shuffle/Search controls for song lists' directly and accurately describes the main objective of the PR—redesigning the controls and adding scroll-aware search behavior across multiple screens.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/apple-music-list-header

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

phanan added 2 commits March 29, 2026 22:05
Make Play/Shuffle buttons fully rounded (pill shape) with borderRadius
100. Attach _scrollController to CustomScrollView in 4 screens where
it was missing, so the scroll-aware search field works.
Show search when scrolling up (negative delta), hide when scrolling
down (positive delta), matching Apple Music behavior where the search
field is above the list content.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/ui/screens/songs.dart (1)

149-160: ⚠️ Potential issue | 🟠 Major

Search-mode state is no longer updated, which can break search requests.

After this refactor, _inSearchMode is never toggled by header events, but request/pagination logic still depends on it (e.g., makeRequest() early return when page == null). This can cause search to stop working in common flows.

💡 Proposed fix
@@
                 SliverToBoxAdapter(
                   child: SongListHeader(
                     sortField: _paginationConfig.sortField,
                     sortOrder: _paginationConfig.sortOrder,
                     scrollController: _scrollController,
-                    onSearchExpanded: () =>
-                        setState(() => _inSearchMode = true),
-                    onSearchCollapsed: () => setState(
-                      () => _inSearchMode = false,
-                    ),
                     onSearchQueryChanged: (query) {
-                      setState(() => _searchQuery = query);
+                      setState(() {
+                        _searchQuery = query;
+                        _inSearchMode = query.trim().isNotEmpty;
+                      });
                       makeRequest();
                     },
                   ),
                 ),
@@
 class SongListHeader extends StatefulWidget {
   final String sortField;
   final SortOrder sortOrder;
   final ScrollController? scrollController;
   final Function(String) onSearchQueryChanged;
-  final Function() onSearchExpanded;
-  final Function() onSearchCollapsed;
@@
   const SongListHeader({
@@
     required this.sortOrder,
     this.scrollController,
     required this.onSearchQueryChanged,
-    required this.onSearchExpanded,
-    required this.onSearchCollapsed,
   }) : super(key: key);

Also applies to: 196-212, 249-252

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ui/screens/songs.dart` around lines 149 - 160, The header callbacks no
longer toggle the _inSearchMode state, breaking search flows; update the
SongListHeader event handlers (onSearchExpanded and onSearchCollapsed) to call
setState to set _inSearchMode = true/false respectively and ensure
onSearchQueryChanged still updates _searchQuery and calls makeRequest(); verify
any other header-related handlers (the occurrences around lines with
SongListHeader and similar callbacks) also update _inSearchMode so pagination
logic in makeRequest() sees the correct search mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ui/screens/album_details.dart`:
- Line 23: The StatefulWidget in album_details.dart creates a ScrollController
named _scrollController and uses it in the CustomScrollView but never releases
it; add an override of dispose() in the State class that calls
_scrollController.dispose() and then super.dispose() to properly clean up the
controller (ensure the dispose method is added to the same State that declares
_scrollController and tied to the CustomScrollView).

In `@lib/ui/screens/downloaded.dart`:
- Line 24: Add a dispose() override in the StatefulWidget's State that owns the
_scrollController (the same State used for the CustomScrollView and
PlayableListHeader) and call _scrollController.dispose() there, then call
super.dispose(); this ensures the ScrollController is released when the
screen/state is destroyed.

In `@lib/ui/screens/podcast_details.dart`:
- Line 24: The state owns a ScrollController (_scrollController) but doesn't
dispose it; add an override dispose() method in the State class that calls
_scrollController.dispose() and then super.dispose() to release resources when
the widget is destroyed (place the method inside the same State that declares
_scrollController).

In `@lib/ui/screens/recently_played.dart`:
- Line 21: The _scrollController is created on the State but never disposed; add
an override of dispose() in the State subclass that declares _scrollController
(the same class where you defined final _scrollController = ScrollController())
and call _scrollController.dispose() before calling super.dispose(); this will
properly clean up the controller and prevent leaks.

In `@lib/ui/widgets/playable_list_header.dart`:
- Around line 54-55: The listener on widget.scrollController calling _onScroll
treats programmatic jumps (e.g. AlphabetScrollbar.jumpTo()) as user scrolls;
change to only react to real user-initiated scrolling by routing scroll handling
through a NotificationListener<UserScrollNotification> (or check for
UserScrollNotification in your scroll notifications) and call _onScroll only
when the notification indicates user scrolling (not programmatic). Update the
widget to stop relying solely on widget.scrollController?.addListener(_onScroll)
and instead handle scroll events in the build via
NotificationListener<UserScrollNotification> (or set/clear a flag around
jumpTo() in AlphabetScrollbar) so _onScroll ignores programmatic jumpTo()
movements.

---

Outside diff comments:
In `@lib/ui/screens/songs.dart`:
- Around line 149-160: The header callbacks no longer toggle the _inSearchMode
state, breaking search flows; update the SongListHeader event handlers
(onSearchExpanded and onSearchCollapsed) to call setState to set _inSearchMode =
true/false respectively and ensure onSearchQueryChanged still updates
_searchQuery and calls makeRequest(); verify any other header-related handlers
(the occurrences around lines with SongListHeader and similar callbacks) also
update _inSearchMode so pagination logic in makeRequest() sees the correct
search mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 705767f7-25dc-44ad-8de8-350fb7e7251e

📥 Commits

Reviewing files that changed from the base of the PR and between 5037d2c and 6161d2a.

📒 Files selected for processing (10)
  • lib/ui/screens/album_details.dart
  • lib/ui/screens/artist_details.dart
  • lib/ui/screens/downloaded.dart
  • lib/ui/screens/favorites.dart
  • lib/ui/screens/genre_details.dart
  • lib/ui/screens/playlist_details.dart
  • lib/ui/screens/podcast_details.dart
  • lib/ui/screens/recently_played.dart
  • lib/ui/screens/songs.dart
  • lib/ui/widgets/playable_list_header.dart

Comment thread lib/ui/screens/album_details.dart
Comment thread lib/ui/screens/downloaded.dart
Comment thread lib/ui/screens/podcast_details.dart
Comment thread lib/ui/screens/recently_played.dart
Comment thread lib/ui/widgets/playable_list_header.dart Outdated
phanan added 3 commits March 29, 2026 22:12
…release

Accumulate scroll delta in each direction and only toggle the search
field after crossing a threshold (30px to show, 50px to hide). This
prevents the small bounce on finger release from immediately hiding
the search.
Add a magnifier icon button to the left of Play/Shuffle. Tapping it
cross-fades the buttons into a search field + Cancel button. Cancel
clears the search and restores the buttons. Removes all scroll
controller logic.
…witcher

Both the buttons row and search row now share the same fixed 48px height
inside an AnimatedSwitcher, eliminating the height interpolation that
caused the layout jump.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ui/widgets/playable_list_header.dart`:
- Around line 23-31: PlayableListHeader currently accepts a scrollController but
never uses it, so the _searching visibility only toggles via button taps; wire
the provided scrollController into the widget lifecycle: in the State for
PlayableListHeader attach a listener to widget.scrollController (or to an
internal controller if null) in initState, reattach in didUpdateWidget when the
controller instance changes, and remove the listener in dispose; in the listener
compute scroll direction/position and call setState to set _searching true when
scrolling down past a threshold and false when scrolling up (and when the search
query is empty), ensuring you guard with mounted and only attach a single
listener to avoid leaks.
- Around line 24-35: The PlayableListHeader currently updates local search state
only, breaking parent screens that rely on expand/collapse events; add two
optional callbacks (e.g., Function()? onSearchOpened and Function()?
onSearchClosed) to the PlayableListHeader constructor and class fields, and
invoke onSearchOpened() inside _openSearch() and onSearchClosed() inside
_closeSearch() (with null checks) so parent screens like Songs can receive the
expand/collapse signals and toggle their _inSearchMode as before; update any
header instantiations to pass handlers where needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: de6220f1-8e4b-4767-bccc-96a30ef9187c

📥 Commits

Reviewing files that changed from the base of the PR and between d7c105f and b3a6ced.

📒 Files selected for processing (1)
  • lib/ui/widgets/playable_list_header.dart

Comment thread lib/ui/widgets/playable_list_header.dart
Comment thread lib/ui/widgets/playable_list_header.dart
phanan added 7 commits March 29, 2026 22:53
The search magnifier button smoothly expands to the right into a
full-width pill containing the text field and an X close button.
Play/Shuffle buttons fade out and shrink as the search pill grows.
Collapsing reverses the animation.
…ffle

Pick the background image based on day-of-month modulo candidate count
so the same list always returns the same image within a day. Prevents
the cover from changing on every rebuild caused by search queries.
Also update button label colors to Colors.white70.
The paginated provider grows the list over time, changing the modulo
result. Cache the background widget so it's only computed once.
@phanan phanan changed the title Apple Music-style Play/Shuffle buttons and scroll-aware search Apple Music-style Play/Shuffle/Search controls for song lists Mar 29, 2026
@phanan phanan changed the title Apple Music-style Play/Shuffle/Search controls for song lists Redesign Play/Shuffle/Search controls for song lists Mar 29, 2026
phanan added 2 commits March 30, 2026 00:50
Add onSearchOpened/onSearchClosed callbacks to PlayableListHeader so
the Songs screen can toggle _inSearchMode to prevent pagination during
search. Also dispose _scrollController in album details.
@phanan phanan merged commit de5b9fc into master Mar 29, 2026
2 checks passed
@phanan phanan deleted the feat/apple-music-list-header branch March 29, 2026 22:56
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.

1 participant