Compare commits

..
145 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 3f9fb5d1e9 Bump version to 2.7.0-beta+30 for release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 07:22:40 +00:00
Your NameandClaude Opus 4.5 62e6498b11 Change quick actions to match album view UI pattern
- Use expandable inline row instead of bottom sheet
- Long-press track to expand action buttons
- Actions: Radio (player picker), Add to Queue (player picker), Favorite (toggle)
- Matches existing album details screen behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:55:36 +00:00
Your NameandClaude Opus 4.5 40dd26519d Remove voice search feature
Removed due to speech_to_text Gradle compatibility issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:35:06 +00:00
Your NameandClaude Opus 4.5 28da46ad05 fix: Downgrade speech_to_text to 6.6.0 for Gradle compatibility
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:33:21 +00:00
Your NameandClaude Opus 4.5 49cdf67c5c fix: Add RECORD_AUDIO permission for voice search
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:28:09 +00:00
Your NameandClaude Opus 4.5 2b6e1d73dc Add quick actions (long-press) for search results
- Add addTrackToQueue method to API and provider
- Add quick action bottom sheet with Play Now, Add to Queue, Play Radio
- Add onLongPress handler to track tiles in search
- Add localization strings for quick actions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:25:13 +00:00
Your NameandClaude Opus 4.5 40debeea06 docs: Update SEARCH-IMPROVEMENTS.md with completed tasks
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:33:30 +00:00
Your NameandClaude Opus 4.5 60a824d55a fix(search): Fix type errors in relevance scoring
- Use item is Album check before accessing inLibrary
- Compare favorite with == true (nullable bool)
- Use item.album?.name instead of albumName

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:33:02 +00:00
Your NameandClaude Opus 4.5 2897a4eaa4 feat(search): Add recent searches history
- Add SearchHistory table to database (schema v4)
- Save successful searches to history (deduplicated, max 10)
- Display recent searches as chips in empty state
- Clicking a chip performs that search
- Add localization strings for search history

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:32:06 +00:00
Your NameandClaude Opus 4.5 c36a971125 feat(search): Add smart cross-referencing for artists
When searching for a track or album title along with an artist name
(e.g., "Yesterday Beatles"), the search now includes related artists
from the matched tracks/albums even if they weren't in the direct
artist search results.

- Extract unique artists from matched tracks and albums
- Filter to artists whose name contains query words (min 3 chars)
- Add cross-referenced artists with lower relevance score (25)
- Avoids duplicates with direct search results

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:28:56 +00:00
Your NameandClaude Opus 4.5 f08f737b6f feat(search): Add unified relevance-based sorting for All results
- Implement relevance scoring algorithm for search results
  - Exact match: 100 points
  - Starts with query: 80 points
  - Word boundary match: 60 points
  - Contains query: 40 points
  - Fuzzy match: 20 points
  - Bonus for library items (+10) and favorites (+5)
  - Secondary field matching for albums/tracks (artist name)

- When viewing "All" filter, results are now merged and sorted by
  relevance score instead of grouped by type with headers

- Add type indicators to subtitles in unified view
  - Albums: "Artist • Album"
  - Tracks: "Artist • Track"
  - Playlists: "Owner • Playlist"
  - Audiobooks: "Author • audiobook"

- Add localization strings: albumSingular, trackSingular

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:20:55 +00:00
Your Name 1161dbb129 Fix PlaylistDetailsScreen constructor parameters 2025-12-27 18:53:38 +00:00
Your NameandClaude Opus 4.5 5d4fc5334c Add playlists and audiobooks to search results (#26)
- Parse playlists and audiobooks from MA search API response
- Add playlist and audiobook filter chips to search screen
- Add playlist and audiobook tiles with navigation to detail screens
- Update cache service and provider to include new media types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 18:44:04 +00:00
Your Name f93494469d Merge fix/issue-27-external-source: External source detection (#27) 2025-12-27 18:19:26 +00:00
Your NameandClaude Opus 4.5 a94fcff758 Fix DLNA external source display bug (#27)
- Add activeSource and isExternalSource fields to Player model
- Detect external sources (optical, Spotify, AirPlay, etc.) from URI patterns
- Show "External Source" label instead of stale metadata
- Use cyan status indicator dot for external sources
- Hide play/pause button when external source is active (controls don't work)
- Clear cached track data when external source detected
- Add localization strings for "External Source" in EN/DE/ES

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 18:01:18 +00:00
Your NameandClaude Opus 4.5 1870724875 Fix Add to Queue button and player auto-selection issues
Issue #26.2: Add to Queue on artist pages
- Added null check for API before calling playArtistRadioToQueue()
- Shows "Not connected" error instead of silent failure

Issue #21: Player selection timing and auto-selection
- Added _sortPlayersSync() helper for reusable smart sorting
- Sort cached players immediately on app resume (no 5s delay)
- Added coldStart parameter to apply full priority logic on launch
- Priority: Playing player > Local player > Last selected > First

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 16:30:43 +00:00
Your NameandClaude Opus 4.5 bd3a2f98e8 Bump version to 2.6.1-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:12:39 +00:00
Your NameandClaude Opus 4.5 516de13894 Fix nav bar flash during first-time user onboarding
Add selectedPlayer check to nav bar visibility condition to prevent
it from showing during the ~1.2s gap between connection and player
selection for first-time users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:35:57 +00:00
Your NameandClaude Opus 4.5 d7188a8784 Fix nav bar flash and backdrop color mismatch
1. Removed Selector for nav bar visibility - Selector only rebuilds when
   isConnected changes, but shouldShowWelcomeBackdrop is local state that
   can change independently. Now using simple if condition.

2. Changed backdrop color from colorScheme.surface to Color(0xFF1a1a1a)
   to match the connection screen exactly, ensuring seamless transition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:18:05 +00:00
Your NameandClaude Opus 4.5 c03ea6a14b Hide bottom nav bar during welcome screen
The nav bar was appearing briefly before the welcome backdrop because
it only checked isConnected, not the welcome screen state. Now it also
hides when shouldShowWelcomeBackdrop is true.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:00:13 +00:00
Your NameandClaude Opus 4.5 d418af45f1 Fix LoginScreen to wait for player selection for first-time users
When a user logs in via LoginScreen (fresh install), the navigation to
HomeScreen was happening immediately on connection - before the player
was selected. This caused the home screen to flash before the welcome
overlay could appear.

For first-time users (hasCompletedOnboarding=false), LoginScreen now
waits for provider.selectedPlayer to be set before navigating, matching
the logic in AppStartup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:44:13 +00:00
Your NameandClaude Opus 4.5 ac05381038 Show welcome backdrop immediately using computed condition
The backdrop was only showing when _isHintModeActive was true, but that
required setState which happens in a post-frame callback - causing a
1-frame gap where the home screen was visible.

Fix: Compute shouldShowWelcomeBackdrop synchronously in build():
- True when first-time user is ready (settings loaded + connected + player)
- True when already in hint mode
- This shows the backdrop IMMEDIATELY in the same frame as HomeScreen
- Welcome content (logo, text) still fades in via animation after

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:26:35 +00:00
Your NameandClaude Opus 4.5 4d29e32651 Fix welcome screen not triggering after connection
The welcome screen check in build() was missing - when the provider state
changes (connection happens), build() runs via context.watch(), but we
weren't checking if conditions were met for the welcome screen.

Added the check in build() with a post-frame callback to trigger the
welcome screen when:
- Settings are loaded
- First-time user (not completed onboarding)
- Connected with player selected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:12:46 +00:00
Your NameandClaude Opus 4.5 91795976ff Fix welcome screen race condition by making AppStartup the gate
The home screen was briefly visible between connection and welcome screens
because AppStartup and GlobalPlayerOverlay were racing - they loaded settings
and detected connection independently with no synchronization.

Solution: Make AppStartup the single source of truth for the transition.
- Load onboarding settings FIRST (before connection check)
- For first-time users: wait for connected + player selected
- For returning users: wait for connected only
- Remove preemptive backdrop from GlobalPlayerOverlay (no longer needed)

This eliminates the race by design - the dark "Connecting..." screen stays
visible until the welcome overlay is ready to appear immediately.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:54:51 +00:00
Your NameandClaude Opus 4.5 484000bc6e Show backdrop during settings load to prevent home screen flash
Add _settingsLoaded flag to track async settings initialization.
Show solid backdrop when connected but settings not yet loaded,
ensuring the grey "Connecting..." background stays visible until
welcome screen is ready.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:23:16 +00:00
Your NameandClaude Opus 4.5 e373053f03 Fix welcome screen flash by using synchronous provider check
Use context.watch() directly in build method instead of nested Selector
to ensure the preemptive backdrop renders in the same frame as home screen.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:05:19 +00:00
Your NameandClaude Opus 4.5 31ef8edaf0 Fix preemptive backdrop not triggering rebuild
The _waitingForConnection flag was set without setState(), so the
preemptive backdrop condition was never re-evaluated. Now it properly
triggers a rebuild so the backdrop shows immediately when connected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:43:47 +00:00
Your NameandClaude Opus 4.5 ff568ebf56 Add preemptive backdrop for seamless welcome transition
When connection is established, a solid backdrop now appears instantly
in the same render frame, covering the home screen before the animated
welcome screen starts. This prevents any flash of the home screen.

Flow: login → solid backdrop (instant) → blur + welcome content (animated)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:18:53 +00:00
Your NameandClaude Opus 4.5 ee6f00d56b Fix welcome screen to only show after successful login
The welcome screen was incorrectly starting immediately on first use,
even before the user had logged in. Now it correctly:

1. Shows login screen first (with hidden nav bar)
2. Waits for successful connection
3. Then shows welcome screen (covering home screen)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 10:04:05 +00:00
Your NameandClaude Opus 4.5 fff5c91b5c Improve welcome screen transition and hide nav bar on login
1. Hide bottom nav bar when not connected (fixes login screen issue)
2. Start welcome screen immediately on first use (blur + logo)
3. Show mini player hints only after connection is established

This ensures a smooth transition from login to home - the welcome
blur covers the home screen immediately, preventing any flash.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 09:47:35 +00:00
Your NameandClaude Opus 4.5 cd27aee7b8 Fix welcome screen showing before server connection
On fresh install, welcome screen was starting immediately even when
user hadn't configured server address/login yet. This meant the hints
were pointing to a mini player that wasn't visible.

Now the welcome screen waits until the user is connected with a
selected player before showing the onboarding experience.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 09:29:14 +00:00
Your NameandClaude Opus 4.5 8773ffa8c4 Welcome screen first-use only + bump version to 2.6.0-beta
- Welcome screen now only shows on first app launch
- Added hasCompletedOnboarding setting (separate from showHints)
- Onboarding marked complete when user skips or swipes to reveal
- Version bumped to 2.6.0-beta+28

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 07:18:48 +00:00
Your NameandClaude Opus 4.5 973652d970 Remove old dismiss hint and fix choppy player selector animation
- Remove "Swipe down, tap outside..." dismissal hint
- Separate backdrop into two widgets:
  - Reveal mode: static backdrop (no TweenAnimationBuilder overhead)
  - Hint mode: animated fade backdrop
- Fixes choppy expand/collapse by avoiding key changes and rebuilds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:55:10 +00:00
Your NameandClaude Opus 4.5 054ad6ab0f Use theme surface color for welcome backdrop instead of black
Matches the dark grey background used elsewhere in the app

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:34:50 +00:00
Your NameandClaude Opus 4.5 e9cc8ce014 Update first-time player hint text
"Choose a player, or dismiss by swiping down"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:26:42 +00:00
Your NameandClaude Opus 4.5 0b8984ef20 Fix welcome screen skip and improve player selector hints
- Welcome screen: only skip button dismisses (not tapping anywhere)
- Player selector onboarding: show "Tap a player to start listening"
- Player selector regular use: show "Tap and hold a player to sync"
- Different icons for each context (touch_app vs lightbulb)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:23:38 +00:00
Your NameandClaude Opus 4.5 2d3c40aa67 Show artist on mini player when device selector is open
- Mini player secondary line now always shows artist/author
- Removed "Now Playing" text when device reveal is visible
- Consistent with showing actual track info

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:34:46 +00:00
Your NameandClaude Opus 4.5 d9946ae7da Hold black for 2 seconds, then fade over 1 second
- Welcome backdrop holds at 100% black for 2 seconds
- Then fades to 50% opacity over 1 second
- Uses Interval curve (0.67-1.0) for the delayed fade effect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:32:45 +00:00
Your NameandClaude Opus 4.5 ab394c41a2 Start welcome screen fully black, fade to blur over 2 seconds
- Backdrop starts at 100% black opacity on first launch
- Fades to 50% opacity over 2 seconds (reveals blurred home screen)
- Hides any loading/rendering happening behind the scenes
- Reveal mode (device selector) uses instant 0.5 opacity (no fade)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:18:47 +00:00
Your NameandClaude Opus 4.5 229e47c31c Welcome screen opens immediately with smooth fade-in
- Start welcome overlay immediately on first launch (no delay)
- Add 800ms fade-in animation for welcome content
- Blur backdrop visible from start, hiding home screen load
- Hint text reduced to 16px (was 18px)
- Bounce animation starts after fade-in completes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:05:11 +00:00
Your NameandClaude Opus 4.5 e2218e8036 Fix welcome hint screen layout and typography
- Split welcome screen into two positioned sections:
  - Top: Logo and "Welcome to Ensemble" (anchored from top)
  - Bottom: Hint text and Skip button (anchored from bottom)
- Fix hint text using theme.textTheme.bodyLarge instead of raw TextStyle
- Create big visual gap between welcome title and hint text
- Equal 32px spacing between hint text <-> skip <-> mini player

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:45:09 +00:00
Your NameandClaude Opus 4.5 43ec560983 Adjust welcome screen: larger hint text and move logo up
- Hint text font size: 18px (was 14px)
- Move content up by 224px (1.5 album covers height)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:11:25 +00:00
Your NameandClaude Opus 4.5 ca1a7804a1 Update welcome screen layout with logo and unified backdrop
- Logo first, then "Welcome to Ensemble", then hint text
- Uses same logo as settings screen (50% width)
- Nicely spaced: 24px after logo, 16px after title, 32px before skip

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:44:17 +00:00
Your NameandClaude Opus 4.5 1f6e5128d0 Unify backdrop for seamless hint to player select transition
- GlobalPlayerOverlay now uses consistent 0.5 opacity for both states
- Removed PlayerRevealOverlay's own backdrop (was causing layered darkness)
- Transition from welcome screen to player select is now seamless

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:40:38 +00:00
Your NameandClaude Opus 4.5 cb8de0e253 Add two-stage onboarding with welcome screen and player hints
Stage 1 - Welcome screen:
- Darker blur backdrop (0.4 opacity) for better focus
- Welcome message above mini player explaining phone as default player
- Skip button to bypass onboarding
- Bouncing mini player continues until user acts

Stage 2 - Player selector hints:
- Shows dismissal hint when coming from onboarding
- "Swipe down, tap outside, or press back to return"

All new text strings are localized.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:19:51 +00:00
Your NameandClaude Opus 4.5 b3ab6db623 Fix hint not triggering when player has no track
The hint now triggers when a player is available, regardless of whether
there's a track playing. The 1500ms delay still gives time for any track
to load for a stable state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:06:03 +00:00
Your NameandClaude Opus 4.5 8f2d947f4f Add favorite tracks cache and interactive hint mode
Favorite tracks:
- Cache favorite tracks to database for instant display on startup
- Load cached favorites when _tracks list is empty

Interactive hint mode:
- Blur/darken backdrop when hint triggers (like player selector)
- Mini player bounces every 2 seconds until user acts
- User can swipe down (reveals player selector) or tap/back to dismiss
- Single bounce animation (600ms) with text overlay

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:46:16 +00:00
Your NameandClaude Opus 4.5 f760d1ff29 Add instant home row loading from database cache
- Pre-load library from SyncService for instant favorites display
  - Favorites now filter from cached albums/artists immediately
  - No need to wait for server connection

- Add HomeRowCache table for discover rows persistence
  - Schema version 3 with migration
  - Stores recent albums, discover artists, discover albums

- Update CacheService to persist and restore home rows
  - loadHomeRowsFromDatabase() loads cached data on startup
  - setCached* methods now persist to database in background

- Update provider initialization order:
  1. Load players from database
  2. Load library from cache (for favorites)
  3. Load home rows from database (for discover)
  4. Then connect to server

This ensures all home screen rows load instantly on app launch,
including favorites and discover rows, without waiting for server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:11:18 +00:00
Your NameandClaude Opus 4.5 440a369a67 Persist playback state for instant track display on app launch
- Fix hint timing to only fire when track is loaded (stable state)
  - Previously hint could fire before track loaded, getting lost in the UI
  - Now waits for currentTrack to be available before showing hint
  - Reduced delay from 3500ms to 500ms since we're waiting for track

- Wire up PlaybackState persistence for currentTrack
  - Load saved track from database on startup (_loadPlaybackStateFromDatabase)
  - Track displays immediately without waiting for server response
  - Persist track changes to database for next app launch
  - Clear persisted state when playback stops

This ensures the mini player shows the correct track info instantly when
the app opens, instead of going through multiple state transitions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 18:49:41 +00:00
Your NameandClaude Opus 4.5 aa9fd3628a Center hint text and make both bounces 20px
- Both bounces now 20px (was 20px + 12px)
- Hint text vertically centered in mini player
- Separate Positioned widget for hint vs track title

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 18:19:32 +00:00
Your NameandClaude Opus 4.5 a812d5c3bf Increase hint double bounce amplitude for visibility
- First bounce: 10px -> 20px
- Second bounce: 6px -> 12px
- Duration: 900ms -> 1000ms
- Single bounce for device selector unchanged at 10px

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:41:00 +00:00
Your NameandClaude Opus 4.5 0af5b3d981 Replace toast with inline hint on mini player
- Add isHintVisible prop to ExpandablePlayer
- When hint visible: show lightbulb icon + "Pull to select players" as primary text
- When hint visible: hide secondary text line (blank)
- Remove separate toast pill widget
- Hint text now appears directly in mini player, bouncing with it

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:37:44 +00:00
Your NameandClaude Opus 4.5 8a5186ac9d Fix hint system: restore bounces, fix toast styling
- Single bounce on device selector expand (when pulling down)
- Single bounce on device selector collapse (cards landing)
- Double bounce on mini player when hint appears
- Toast pill: no bounce, positioned over nav bar, proper font
- Added decoration: TextDecoration.none to prevent underlines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:14:56 +00:00
Your NameandClaude Opus 4.5 0fbbf498e6 Fix: use onInverseSurface instead of inverseOnSurface
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 16:55:25 +00:00
Your NameandClaude Opus 4.5 9963f2f376 Refactor hint system: toast pill, hint-only bounce, fix hint text
- Remove double bounce from device selector expand/collapse
- Create separate hint-only bounce animation (doesn't affect player)
- Change pull-to-select hint to toast pill box at bottom of screen
- Update "hold to sync" text to "Tap and hold a player to sync"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 16:46:50 +00:00
Your NameandClaude Opus 4.5 1448da03b3 Adjust pull hint: move up 5px, delay 2s longer
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:55:18 +00:00
Your NameandClaude Opus 4.5 f2ee2c2c5c Fix pull-to-select hint position to half-overlap mini player
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:41:21 +00:00
Your NameandClaude Opus 4.5 1648910153 Add lightbulb icon to swipe-to-change-player hint
- Add isHint parameter to MiniPlayerContent
- Show lightbulb icon when secondary text is a hint
- Apply to both main and peek player content in DeviceSelectorBar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:17:28 +00:00
Your NameandClaude Opus 4.5 c1fe9e2148 Add lightbulb icon to hold-to-sync hint
Consistent with pull-to-select hint styling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:16:05 +00:00
Your NameandClaude Opus 4.5 d40941bf13 Improve pull-to-select hint with double bounce and lightbulb icon
- Reposition hint to half-overlap mini player top edge
- Add double bounce animation (first 10px, second 6px)
- Add lightbulb icon before hint text
- Extend animation duration to 900ms for better visibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:12:59 +00:00
Your NameandClaude Opus 4.5 01349f8336 Fix hint text styling and adjust timing
- Wrap hint text in Material widget to fix typewriter font/yellow underline
- Increase delay before showing hint: 500ms → 1500ms
- Increase linger time: 800ms → 2500ms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 12:07:26 +00:00
Your NameandClaude Opus 4.5 439889fc3e Improve hint text styling and simplify swipe text
- Match hold-to-sync hint font size (16) with player card primary text
- Change text to "Hold a player to sync"
- Remove pill container from pull-to-select hint
- Make pull hint bounce with the mini player
- Simplify swipe text to just "Swipe to change player"
- Update German and Spanish translations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:52:53 +00:00
Your NameandClaude Opus 4.5 5f2ef72910 Power on Cast/Sendspin players before syncing
When long-pressing a powered-off Cast player that has a Sendspin
counterpart, automatically power it on first and wait for it to
become available before syncing. This provides a seamless experience
for grouping Cast devices that are currently off.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:42:19 +00:00
Your NameandClaude Opus 4.5 0f83bb842b Persist Cast-to-Sendspin ID mapping for offline grouping
Don't clear the _castToSendspinIdMap on each player refresh. This allows
syncing to work with powered-off Cast devices by remembering their
Sendspin IDs from when they were previously online.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:35:48 +00:00
Your NameandClaude Opus 4.5 5fc8623bcb Improve hint system and allow syncing powered-off players
- Fix hint text decoration to prevent underlines
- Make hold-to-sync hint slide with player cards instead of fading
- Show pull-down hint on every app launch (not just first time)
- Replace arrow symbols (← ↓ →) with text (left, down, or right)
- Allow long-press to sync players even when powered off
- Update translations for German and Spanish

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:28:30 +00:00
Your NameandClaude Opus 4.5 e3403c5daf Add hint system with settings toggle
- Add show hints toggle in new Hints & Tips settings section
- Add pull-to-select-players hint with bounce animation on first connection
- Add hold-to-sync hint in player reveal overlay
- Track when user has discovered player reveal feature
- Update swipe hint to show directional arrows: ← ↓ →
- Support English, German, and Spanish translations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:00:10 +00:00
Your Name 65e8e87e01 Bump version to 2.5.0-beta+27 2025-12-26 09:11:22 +00:00
Your NameandClaude Opus 4.5 2658be1a3f Add nowPlaying localization for device selector UI
Adds translations:
- English: "Now playing"
- German: "Wird gespielt"
- Spanish: "Reproduciendo ahora"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:24:57 +00:00
Your NameandClaude Opus 4.5 2a9a4b2494 Show player name in mini player when device selector is open
When the device reveal overlay is visible:
- Mini player shows player name instead of track name
- Secondary line shows "Now Playing" instead of artist
- Adds blur backdrop behind device selector for focus

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:15:47 +00:00
Your NameandClaude Opus 4.5 6ff1b8b95f Fix grouping: translate Cast player ID to Sendspin ID when syncing
When showing the regular Cast player (ungrouped state), sync operations
need to use the corresponding Sendspin player ID since only Sendspin
players can group with other Sendspin players.

Added _castToSendspinIdMap to track the mapping between regular Cast
and Sendspin player IDs, populated during player filtering.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 06:51:12 +00:00
Your NameandClaude Opus 4.5 06700f54c8 Smart Sendspin/Cast player switching based on group status
- When grouped: show Sendspin version (for sync), hide original Cast
- When ungrouped: show original Cast (for power control + queue)

This preserves power control and proper queue behavior when not syncing,
while still providing seamless Sendspin grouping when needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 06:02:27 +00:00
Your NameandClaude Opus 4.5 489e2c3f3a Prefer Sendspin version of Cast players in player list
When a Cast device has Sendspin mode enabled, both the regular Cast
player and the "(Sendspin)" version appear in Music Assistant. This
change:
- Automatically prefers the Sendspin version when both exist
- Removes the "(Sendspin)" suffix for a cleaner display
- Provides seamless transitions when Sendspin becomes available

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 23:06:54 +00:00
Your NameandClaude Opus 4.5 60fb374d0f Fix Sendspin: always report synchronized state, rename timestamp field
- Change playerState from conditional 'synchronized'/'idle' to always 'synchronized'
  (MA only accepts 'synchronized', 'error', 'external_source')
- Rename 'timestamp' to 'client_transmitted' in time messages per protocol spec

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 22:40:58 +00:00
Your NameandClaude Opus 4.5 8946373a28 Reduce grouped border to 1.5px everywhere
Thinner border for a more subtle grouped indicator on:
- Mini player
- Player cards in reveal overlay
- Player selector sheet

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 10:00:55 +00:00
Your NameandClaude Opus 4.5 00560ed1ec Show leader's queue and track for grouped child players
When a player is synced/grouped as a child:
- getQueue() now returns the leader's queue
- getCachedTrackForPlayer() returns the leader's track
- selectPlayer() gets the leader's track from cache

This ensures grouped players show the same queue and track info
as their leader, reflecting the actual audio being played.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:40:56 +00:00
Your NameandClaude Opus 4.5 6b13e6e8f7 Add yellow border to mini player when grouped
Shows the same pastel yellow border (#FFF59D) on the mini player
that is shown on grouped player cards in the reveal overlay.
Only visible when collapsed (t < 0.5) to indicate group membership.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:38:28 +00:00
Your NameandClaude Opus 4.5 957cc170a8 Fix isGrouped to include child players with syncedTo
Child players (like Dining Room) have empty group_members but have
syncedTo set to the leader. Updated isGrouped to check for both:
- Leader: group_members.length > 1
- Child: syncedTo != null

Both should show the yellow border now.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:10:50 +00:00
Your NameandClaude Opus 4.5 91a00c62b0 Add long-press sync to PlayerCard in reveal overlay
The device list uses PlayerCard widget (not _PlayerSelectorSheet).
Added:
- onLongPress callback to PlayerCard
- isGrouped property for pastel yellow border
- Long-press handler in player_reveal_overlay.dart
- SnackBar feedback when long-press detected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:41:39 +00:00
Your NameandClaude Opus 4.5 cb1122277a Add snackbar feedback for long-press sync detection
- Show visual snackbar "Syncing {player}..." when long-press detected
- Remove !isSelected condition to allow sync on any available player
- Helps debug if long-press gesture is being recognized

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:39:46 +00:00
Your NameandClaude Opus 4.5 190fa4f61c Add detailed logging for player sync debugging
Logs added at:
- UI: Long-press trigger with player details
- Provider: togglePlayerSync, syncPlayerToSelected
- API: syncPlayerToLeader with request/response

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:29:35 +00:00
Your NameandClaude Opus 4.5 2c02473f32 Fix sync API command: use players/cmd/group
The correct MA API command is:
- players/cmd/group (not group_many)
- player_id: the player joining the group
- target_player: the leader player to sync to

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:05:52 +00:00
Your NameandClaude Opus 4.5 79962517ae Add temporary player grouping via long-press
Feature: Long-press on a player in the device list to sync it with the
currently selected/playing player, creating a temporary group.

Changes:
- API: Add syncPlayers() and unsyncPlayer() methods using MA commands
  players/cmd/group_many and players/cmd/ungroup
- Model: Add groupMembers and syncedTo fields to Player, with derived
  properties isGrouped, isGroupLeader, isGroupChild
- Provider: Add syncPlayerToSelected(), unsyncPlayer(), togglePlayerSync()
- UI: Long-press gesture on player cards (with haptic feedback)
- UI: Pastel yellow border (#FFF59D) around grouped players
- UI: "Synced" badge with link icon for grouped players

UX Flow:
1. Play music on a speaker (e.g., Kitchen HiFi)
2. Open device selector
3. Long-press another speaker (e.g., Bedroom)
4. Bedroom syncs to Kitchen and both show yellow border
5. Long-press again to unsync

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:25:39 +00:00
Your NameandClaude Opus 4.5 8641c89b9b Fix badge count and auto-selection priority logic
- Badge now excludes selected player from count (shows "other" activity)
- Auto-selection now switches to playing player when preferLocalPlayer is OFF
  and current selection is not playing

Addresses feedback from issue #21

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:21:27 +00:00
Your Name 8f1a1cd3bd Bump version to 2.4.1-beta+26 2025-12-24 21:50:59 +00:00
Your NameandClaude Opus 4.5 c841898d8c Add image pre-caching to album and artist rows
The root cause: second tap is smooth because image is cached,
but first tap shows grey flash because image isn't cached yet.

Recent Albums works perfectly because those images are already
cached from when the user played those albums.

Fix: Pre-cache the first 10 album/artist images when the row
loads, so they're ready in cache before the user taps.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 20:33:47 +00:00
Your NameandClaude Opus 4.5 bd2c2be54e Fix hero animation grey icon flash - root cause fixes
Artist card layout:
- Replace Expanded > Center > LayoutBuilder > Hero with AspectRatio > Hero
- LayoutBuilder inside Hero caused dynamic sizing during animation
- AspectRatio provides fixed geometry BEFORE hero animation starts

Album details screen:
- Defer _loadFreshAlbumData until 350ms after transition (same as colors)
- Previously called immediately in initState, causing setState during animation
- If fresh album had different image URL, caused grey icon flash

These are the actual root causes identified by comparing "Recent Albums"
(perfect animation) with other album/artist taps (janky animation).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 20:19:35 +00:00
Your NameandClaude Opus 4.5 1225255558 Comprehensive animation performance optimizations
Critical fixes:
- Replace Opacity widgets with color alpha to avoid saveLayer in player_controls
- Fix AnimatedBuilder not using child parameter in global_player_overlay
- Replace setState with ValueNotifier for bounce animation to prevent rebuilds
- Add flightShuttleBuilder to series Hero to simplify animation flight
- Remove Opacity+ClipRRect combo in expandable_player progress bar

Image loading optimizations:
- Add fadeInDuration: Duration.zero to audiobook_row, track_row, queue_panel
- Add memCacheWidth/Height to queue_panel (176x176 for 44x44 display)
- Move _loadQueue() from AnimationStatus.forward to .completed

Animation polish:
- Add curve: Curves.easeInOut to AnimatedContainer in settings_screen
- Use FadeSlidePageRoute for series navigation for consistent hero timing
- Add clipBehavior: Clip.hardEdge to progress bar ClipRRect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 19:12:04 +00:00
Your NameandClaude Opus 4.5 4589d26039 Fix hero animation flicker in card widgets
Set fadeInDuration: Duration.zero on source card widgets to prevent
end-of-animation flicker:
- album_card.dart: was 150ms, now zero
- artist_card.dart: was 150ms, now zero
- artist_avatar.dart: added zero fade
- series_row.dart: added zero fade

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:44:43 +00:00
Your NameandClaude Opus 4.5 aa8a3413f2 Complete hero animation audit: add fadeInDuration and initialImageUrl
- audiobook_detail_screen: Add fadeInDuration: Duration.zero to hero image
- audiobook_series_screen: Add fadeInDuration to all 3 CachedNetworkImage usages
- audiobook_author_screen: Add initialAuthorImageUrl parameter for smooth hero,
  add fadeInDuration to all 3 CachedNetworkImage usages
- new_library_screen: Pass initialAuthorImageUrl to author screen navigation,
  pass initialImageUrl to audiobook grid cards, add fadeInDuration to author images

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:18:30 +00:00
Your NameandClaude Opus 4.5 f0c26cd96d Fix artist hero animation jank by using CachedNetworkImage
ArtistDetailsScreen was using Image.network which re-fetches images
from the network, ignoring the cache. This caused jank during hero
animations because the image wasn't ready instantly.

Changed to CachedNetworkImage with zero fade duration to match
AlbumDetailsScreen behavior - image loads instantly from cache
for smooth hero transitions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 15:48:52 +00:00
Your NameandClaude Opus 4.5 591923b43b Pass initialImageUrl to all detail screens for smooth hero animations
Apply the same pattern that fixed album card hero animations to all
navigation paths:
- AudiobookDetailScreen now accepts initialImageUrl parameter
- Library screen passes imageUrl when navigating to audiobooks/albums
- Audiobook series and author screens pass imageUrl
- Artist details screen passes imageUrl to album navigations
- Search screen passes imageUrl to album details

This ensures hero animations show the correct image throughout the
transition instead of showing a placeholder icon.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 14:52:41 +00:00
Your NameandClaude Opus 4.5 83b46ef916 Fix queue restore: properly serialize providerMappings and check for them
Two issues prevented queue restoration from working:

1. MediaItem.toJson() was passing providerMappings directly instead of
   converting each ProviderMapping to JSON. This meant jsonEncode would
   fail to serialize them, losing the provider mapping data.

2. _restoreQueueFromCache was filtering for track.uri, but queue items
   from MA API don't include URIs. playTracks uses providerMappings to
   build URIs via _buildTrackUri(), so we should check for those instead.

Now providerMappings are properly serialized/deserialized, and queue
restore checks for available providerMappings (or provider+itemId as
fallback) to match what playTracks expects.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 14:18:08 +00:00
Your NameandClaude Opus 4.5 c9300ec4d4 Pass imageUrl to AlbumDetailsScreen for seamless hero animation
When navigating from AlbumCard to AlbumDetailsScreen, the detail screen's
first build may not have access to image metadata immediately. This caused
the hero animation to flash to a record icon even though both screens
eventually show the artwork.

Fix: AlbumCard now passes its imageUrl to AlbumDetailsScreen via a new
initialImageUrl parameter. The detail screen uses this as a fallback,
ensuring the hero animation has the same image on both ends.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:51:15 +00:00
Your NameandClaude Opus 4.5 cd7012258f Always fetch fresh recent albums from API for complete image data
getRecentAlbumsWithCache was short-circuiting to local database data
which has OLD entries without image metadata. This prevented fresh
albums with images from ever being fetched.

Now the function always fetches from API when connected. Local/cached
data is only used:
- By getCachedRecentAlbums() for instant display in stale-while-revalidate
- As fallback when API is unavailable or errors

This ensures albums passed to detail screen have complete metadata
including images for proper hero animation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:47:34 +00:00
Your NameandClaude Opus 4.5 041258cfd9 Always update rows with fresh data to ensure images are available
The stale-while-revalidate pattern had a flawed hasChanged check that
only compared item IDs and counts. If cached albums had the same items
but without image metadata, fresh albums (with images) would never
replace them.

This caused the hero animation to show a record icon because the user
would tap on a cached album without images before fresh data loaded.

Now AlbumRow and ArtistRow always update with fresh data when it arrives,
ensuring albums have complete metadata including images for proper
artwork display.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:26:58 +00:00
Your NameandClaude Opus 4.5 7783899e49 Fetch full album details in getRecentAlbums for artwork
The getRecentAlbums() was extracting album data from track responses,
which only contains simplified/nested album objects without the full
metadata (including images array). This caused missing artwork in the
recent albums row and album details screen.

Now the function:
1. First fetches tracks to collect unique album URIs
2. Then fetches full album details for each URI via music/item_by_uri

This ensures albums have complete metadata with images for proper
artwork display and hero animations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 13:09:23 +00:00
Your NameandClaude Opus 4.5 6724b1814e Store album images metadata in recently played for instant artwork
When recording album plays, now stores the images array from album
metadata. When reconstructing albums from recently played data, this
image metadata is included, enabling getImageUrl() to work without
needing to fetch fresh album data from the API.

This fixes the artwork "pop-in" issue where album art would flash into
place at the end of the hero animation when navigating from the recent
albums row to album details screen.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 12:53:25 +00:00
Your NameandClaude Opus 4.5 228b281fc8 Fix album artwork in detail screen for recently played albums
When tapping an album from recently played, the detail screen now
fetches fresh album data from the API to get full metadata including
the images array needed for artwork display.

- Added _freshAlbum state variable to store full album data
- Added _displayAlbum getter that returns fresh data if available
- _loadFreshAlbumData() fetches album by URI and triggers color extraction
- Build method now uses _displayAlbum for image URL

This fixes the placeholder icon showing in album details when navigating
from the recently played row.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:51:09 +00:00
Your Name a59ca9ecd9 Revert "Fix album artwork missing for recently played albums"
This reverts commit 85e1f6142f.
2025-12-24 11:49:38 +00:00
Your NameandClaude Opus 4.5 85e1f6142f Fix album artwork missing for recently played albums
The recently played albums were being stored with minimal metadata,
missing the 'images' array needed for getImageUrl().

Now recordAlbumPlayed stores the full album JSON including metadata,
and getRecentAlbums reconstructs full Album objects from that JSON.

Note: This only fixes newly played albums. Existing entries in the
database will still show placeholder icons until played again.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:33:28 +00:00
Your NameandClaude Opus 4.5 04b8f4f505 Fix resume playback when server queue is empty
When the app returns after a while, Music Assistant server may have
cleared its queue. The app would fail with "No playable item found".

Now when this error occurs, the app automatically:
1. Detects the "No playable item" error
2. Loads the cached queue from the database
3. Extracts tracks and re-queues them on the server
4. Starts playback from the beginning

This fixes the issue where Chris' phone couldn't play after
returning to the app - the 35 cached tracks are now restored.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:01:54 +00:00
Your NameandClaude Opus 4.5 f2754b6f2a Add stale-while-revalidate pattern to home screen rows
- AlbumRow and ArtistRow now show cached data instantly (no loading spinner)
- Fresh data is fetched in background and UI updates silently if changed
- Added getCachedAlbums/getCachedArtists optional params for sync cache access
- Added cache getter methods to MusicAssistantProvider

This fixes the issue where home screen rows showed loading spinners
when returning to the app after the 10-minute cache expired.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 10:49:47 +00:00
Your NameandClaude Opus 4.5 bdfce8c8cb Add offline action queuing for favorites
- Created OfflineActionQueue service to persist actions when offline
- Queue is stored in SharedPreferences and processed on reconnect
- Updated all favorite toggle methods in screens to use new provider methods
- Provider methods check connection state and queue if offline
- Optimistic UI updates with "Will sync when online" feedback
- Added localization strings for offline sync messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:45:39 +00:00
Your NameandClaude Opus 4.5 431b498156 Add queue persistence to database
- Queue is now persisted to database when fetched from API
- Expandable player shows cached queue immediately on load
- Added getCachedQueue() method to MusicAssistantProvider
- Background refresh updates queue silently

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:31:02 +00:00
Your NameandClaude Opus 4.5 a9570c6106 Add silent background refresh pattern to detail screens
Album, playlist, and artist detail screens now:
- Show cached data immediately (if available)
- Fetch fresh data in background (silent refresh)
- Update UI only if data actually changed

Added cache accessor methods to MusicAssistantProvider:
- getCachedAlbumTracks()
- getCachedPlaylistTracks()
- getCachedArtistAlbums()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:29:42 +00:00
Your NameandClaude Opus 4.5 337ca775c8 Fix variable name conflict in search_screen.dart
Renamed hasResults to hasCachedResults to avoid conflict with
existing hasResults variable later in the same function.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:10:41 +00:00
Your NameandClaude Opus 4.5 183ec8024c Fix generated type names and eliminate loading spinners
Type name fixes:
- CachedPlayers table generates CachedPlayer data class (singular)
- Fixed type references in database.dart and database_service.dart

Loading spinner elimination:
- Show cached data immediately if available, even while loading
- Only show spinner if no cached data AND loading
- Applied to library_albums, library_artists, library_tracks, library_playlists
- Updated search_screen to show cached results while fetching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 09:01:49 +00:00
Your NameandClaude Opus 4.5 e32f4fa591 Stop aggressive cache clearing and persist players to database
Cache clearing changes:
- Remove cache clearing on disconnect events in both providers
- Keep cached players and data for instant UI display on reconnect
- Add clearAllOnLogout() method for explicit logout clearing
- Add clearCachesOnLogout() to ConnectionProvider

Player persistence:
- Add player caching convenience methods to DatabaseService
- Load cached players from database on app startup (instant display)
- Persist players to database when loaded from API
- Restore selected player from database on startup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 08:56:40 +00:00
Your NameandClaude Opus 4.5 e175c14f69 Add persistent caching tables for playback state, players, and queue
- Add PlaybackState table: persists current track, position, playing state
- Add CachedPlayers table: stores player list with current track info
- Add CachedQueue table: persists queue items per player
- Increment schema version to 2 with migration logic
- Add CRUD operations for all new tables

Part of aggressive caching overhaul for instant app resume.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 08:51:07 +00:00
Your NameandClaude Opus 4.5 88a4cf333b refactor: Extract shared MiniPlayerContent widget for uniformity
Create a unified MiniPlayerContent widget used by all mini player
components to ensure pixel-perfect consistency:

- New MiniPlayerLayout class with shared constants:
  - height: 64px, artSize: 64px
  - textLeft: 74px, primaryTop: 13px, secondaryTop: 33px
  - iconSize: 28px, iconOpacity: 0.4
  - primaryFontSize: 16px, secondaryFontSize: 14px

- DeviceSelectorBar now uses MiniPlayerContent
- ExpandablePlayer uses MiniPlayerLayout constants for collapsed state
- Peek content uses MiniPlayerContent via Transform.translate

This eliminates the drift between components that was causing visual
jumps when swiping between playing and non-playing players.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 07:02:27 +00:00
Your NameandClaude Opus 4.5 878c473096 fix: Badge text styling and peek icon opacity
- Add explicit fontSize (11) and color to badge text for visibility
- Fix peek icon color: use textColor.withOpacity(0.4) to match
  DeviceSelectorBar (was using full opacity onSurfaceVariant)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 06:53:24 +00:00
Your NameandClaude Opus 4.5 80ac93c867 fix: Match peek content to DeviceSelectorBar exactly
- Remove height: 1.2 from peek content text (DeviceSelectorBar doesn't have it)
- Make height: 1.2 conditional on expanded state for main title
- Fix icon size in peek content: 28px to match DeviceSelectorBar

These mismatches caused visible text movement and icon size
changes when swiping from playing to non-playing player.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 06:36:46 +00:00
Your NameandClaude Opus 4.5 23567c6c7a fix: Use Badge widget and match mini player layouts
- Replace manual badge implementation with Flutter's Badge widget
  for proper centering and styling
- Standardize mini player text positioning between playing and
  non-playing states:
  - Title: left=74px (art+10), top=13px
  - Artist: left=74px, top=33px (gap of 20)
- Convert DeviceSelectorBar to absolute positioning to match
  expandable player structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 06:19:59 +00:00
Your Name bccf069379 Revert "fix: Match playing mini player layout to non-playing"
This reverts commit 62c300090d.
2025-12-23 22:16:48 +00:00
Your NameandClaude Opus 4.5 62c300090d fix: Match playing mini player layout to non-playing
- Left padding: 8px → 12px
- Title-artist gap: 22px → 18px
- Vertical centering adjusted
- Remove height: 1.2 from collapsed title (was causing offset)
- Badge: larger (22px), bolder (w900), better centered

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:56:32 +00:00
Your NameandClaude Opus 4.5 6b3e2e8cb6 fix: Improve badge vertical centering and make bolder
- Adjust padding to shift number up (less top padding)
- Increase font weight to w800 (extra bold)
- Set line height to 1.0 for tighter baseline

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:20:23 +00:00
Your NameandClaude Opus 4.5 daae42fbdc fix: Prefer Local Player now takes priority over current selection
When "Prefer Local Player" is enabled, the local player is now selected
even if another player was already selected. This ensures that when the
local player registers (via Sendspin), it becomes selected automatically.

Previously, the "keep current selection" logic ran before checking the
prefer local setting, which meant the local player was never selected
when it registered after initial connection.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:04:15 +00:00
Your NameandClaude Opus 4.5 23cd2df5ca feat: Add player UX improvements (Issue #21)
1. Smart Player Auto-Selection:
   - Priority 1: Single playing player (skip if multiple playing)
   - Priority 2: Local player (this device)
   - Priority 3: Last manually selected player
   - Priority 4: First available player
   - New "Prefer Local Player" toggle skips to local player first

2. Active Player Count Badge:
   - Shows number of currently playing devices
   - Round badge with tertiary color in top-right of device selector

3. Dynamic Player List Sorting:
   - New "Smart Sort" toggle in settings
   - When enabled: Local player → Playing → On → Off
   - Applies to device list and mini player swipe
   - Default: alphabetical (unchanged)

4. Settings reorganization:
   - Moved Language selector to its own section
   - Added new Players section with toggles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:48:06 +00:00
Your NameandClaude Opus 4.5 2c7f578fd1 feat: Add accent color selection in settings
Add 6 predefined accent colors (purple, blue, teal, deep orange, pink,
green) that users can choose when Material You is disabled. The colors
are displayed as circular swatches above the Material You toggle and
are greyed out when Material You is enabled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 20:15:35 +00:00
Your NameandClaude Opus 4.5 d7bfb66fab fix: Reduce play button nudge to add space from skip
Play: +3px, Skip: +6px (more space between play and skip)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:56:19 +00:00
Your NameandClaude Opus 4.5 222a66db61 fix: Nudge play and skip buttons toward power
Move play and skip 6px right instead of moving power left

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:38:55 +00:00
Your NameandClaude Opus 4.5 8eec9599f3 fix: Revert to Material Icons, use Transform to close gap
- Reverted MDI icons back to Material Icons
- Used Transform.translate(-6, 0) to nudge power button left
- This closes the gap between skip and power without changing icon pack

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:37:41 +00:00
Your NameandClaude Opus 4.5 fe45a34c35 feat: Switch to MDI icons for tighter spacing
Replace Material Icons with Material Design Icons (MDI) in:
- Device list: play/pause, skip next, power
- Mini player: skip previous, play/pause, skip next
- Expanded player: shuffle, repeat, skip controls, audiobook controls

MDI icons have less built-in whitespace for more compact layouts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:21:54 +00:00
Your NameandClaude Opus 4.5 232862b164 fix: Remove gap between skip and power buttons
Set padding to zero on both buttons for minimal spacing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:07:37 +00:00
Your NameandClaude Opus 4.5 dc6004ac00 feat: Asymmetric device list animation timing
- Expand: 200ms (slower, more deliberate reveal)
- Collapse: 150ms (snappier dismiss)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 14:00:34 +00:00
Your NameandClaude Opus 4.5 62fc5ab2f0 fix: Reduce gap between skip and power buttons
Reduced power button horizontal padding from 4 to 2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:51:39 +00:00
Your NameandClaude Opus 4.5 807bebbab9 fix: Restore skip button to original size
Keep skip next at 28, only reduce play (28) and power (20)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:05:29 +00:00
Your NameandClaude Opus 4.5 7edf7bbfc8 fix: Reduce device selector button sizes for better alignment
- Play/Pause: 34→28 icon size, smaller container
- Skip next: 28→24 icon, reduced horizontal padding
- Power button: 28→20 (smallest of the three)
- Reduced gaps between buttons for more compact layout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:03:26 +00:00
Your NameandClaude Opus 4.5 c4ad0a6279 fix: Match play button sizing exactly to mini player
Use SizedBox wrapper with zero padding for play button, matching
the mini player's Container approach. Also restore right margin to 4px.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:39:12 +00:00
Your NameandClaude Opus 4.5 6ebf2ed15b feat: Update device list buttons to play/pause, next, power
- Change button order from prev/play/next to play/pause/next/power
- Match mini player button sizes (play=34, skip/power=28)
- Power button always visible when player available (dimmed when off)
- Play/pause and next only shown when powered with content

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:23:16 +00:00
Your NameandClaude Opus 4.5 91b713aab8 fix: Back gesture priority for device list over navigation
Add device list (player reveal) to didPopRoute() priority chain.
Previously back gesture would pop navigation routes before dismissing
the device list when on screens other than home.

Priority order is now: device list > expanded player > navigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:55:26 +00:00
Your NameandClaude Opus 4.5 8ce567a1cf perf: Revert TweenSequence to simple CurvedAnimation for smoother FPS
TweenSequence with chained curves was causing low FPS on the mini player
to expanded player animation. Reverted to simple CurvedAnimation which
is more performant:

- Replace 3-phase TweenSequence with single CurvedAnimation (easeOutCubic)
- Reduce expand duration from 350ms to 280ms
- Simplify bounce animation back to sine-wave calculation
- Keep gesture-driven expand/collapse functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:41:23 +00:00
Your NameandClaude Opus 4.5 2081cf8552 fix: Device list swipe-down and back gesture handling
- Fix swipe-down gesture on active mini player to reveal device list
  (was blocked by gesture tracking starting immediately)
- Fix back gesture priority: device list now closes before underlying
  album/artist screens by handling PopScope at GlobalPlayerOverlay level

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 10:37:10 +00:00
Your NameandClaude Opus 4.5 8dae062663 fix: Correct swipe direction and faster collapse
- Fix inverted drag: swipe down now moves cards down (toward dismiss)
- Faster collapse animations:
  - Mini player: 250ms → 200ms
  - Player list: 200ms → 150ms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:33:32 +00:00
Your NameandClaude Opus 4.5 59eb80c11b fix: Animation refinements based on testing feedback
- Faster collapse (250ms vs 350ms expand) for snappier feel
- Simplified bounce to single clean motion (down 10px → back)
  - Removed overshoot phases that looked like multiple bounces
  - Duration reduced to 200ms
- Fixed swipe dismiss on player list vanishing halfway
  - Reset dragOffset before dismiss() so animation starts correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 09:13:55 +00:00
Your NameandClaude Opus 4.5 d118dd8fe1 feat: Gesture-driven expand/collapse with keyframe animations
- Replace threshold-based expand/collapse with finger-tracking
  - Mini player now follows finger during vertical swipe
  - Snap decision based on velocity (800px/s) and position (30%)

- Add TweenSequence for phased animations
  - Phase 1 (0-40%): Quick acceleration with easeOut
  - Phase 2 (40-75%): Consistent cruise with linear
  - Phase 3 (75-100%): Settle with slight overshoot (easeOutBack)

- Fix position calculation discontinuity
  - Removed abrupt switch at t=0.1
  - Now smoothly fades out slide/bounce offsets over first 15%

- Add settle bounce when cards collapse into mini player
  - TweenSequence bounce: push down 12px → overshoot -4px → settle
  - Triggered when cards are 70% collapsed for natural "landing" feel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:53:38 +00:00
Your Name 64b36d37ad Revert test: restore normal mini player colors 2025-12-23 07:32:09 +00:00
Your Name 339e55df93 TEST: Bright red mini player background to verify build freshness 2025-12-23 07:13:48 +00:00
Your Name 86f3798637 Add unified device UI with swipe-down reveal animation
Cherry-picked commits 39f43cc through d0ffd4a from feature/unified-device-animation
to get clean build state before Gradle issues.
2025-12-23 06:57:44 +00:00
Your NameandClaude Opus 4.5 d5c96880d7 chore: Bump version to 2.3.3-beta+25
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 10:10:43 +00:00
Your NameandClaude Opus 4.5 540b95f558 Add MA version detection for Sendspin proxy support
- Add _parseServerVersion() helper for version comparisons
- Add _serverHasSendspinProxy() to detect MA 2.8.0+ (has /sendspin proxy)
- Add _logSendspinTroubleshooting() with version-specific guidance
- Update connection logic to try local proxy fallback for local IPs
- Improve logging to show server version and proxy availability
- MA 2.7.x users now get clear guidance: upgrade to 2.8.0+ or configure proxy

Fixes #17

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 21:50:54 +00:00
Your Name a1c84f29c1 fix: Fix followRedirects usage and add missing painting import 2025-12-21 21:00:02 +00:00
Your Name 2cf3135f18 fix(ci): Add flutter gen-l10n step to build workflow 2025-12-21 20:46:21 +00:00
Your NameandClaude Opus 4.5 1b33c57cb7 fix: Improve local IP and Sendspin connection handling
Fixes issue #17 - users unable to connect via local IP or see local player

Changes:
1. **Local IP connection handling**:
   - Don't follow HTTP->HTTPS redirects for local IPs
   - Detect 308/301/302 redirects and handle gracefully
   - Prevents certificate verification errors when server redirects to HTTPS

2. **Sendspin connection logic**:
   - Remove non-existent API calls (sendspin/connection_info, webrtc_offer, etc.)
   - For local IPs: Connect directly to ws://ip:8927/sendspin
   - For external/HTTPS: Use proxy at wss://server/sendspin with auth
   - Better error messages explaining what to configure

3. **Utility improvements**:
   - Add _isLocalNetworkHost() helper for IP detection
   - Include .ts.net (Tailscale) as local network

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 20:35:52 +00:00
Your NameandClaude Opus 4.5 3708b24d55 feat(i18n): add Spanish language support with improved picker UI
- Add complete Spanish translations (app_es.arb)
- Replace cramped SegmentedButton with ListTile + Dialog picker
- Localize player states (Off, Idle, Unavailable, Selected)
- Localize Material You description
- Scales better for multiple languages
- Bump version to 2.3.2-beta+24

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 09:27:23 +00:00
55 changed files with 6617 additions and 1140 deletions
+3
View File
@@ -41,6 +41,9 @@ jobs:
- name: Generate code (Drift database)
run: dart run build_runner build --delete-conflicting-outputs
- name: Generate localizations
run: flutter gen-l10n
- name: Generate launcher icons
run: flutter pub run flutter_launcher_icons
+52
View File
@@ -0,0 +1,52 @@
# Search Improvements
Branch: `feature/search-improvements`
## Completed
- [x] Add playlists and audiobooks to search results
- Parse from MA API response
- Filter chips for each type
- Tile builders with navigation to detail screens
- [x] Unified Relevance-Based "All" Results
- Scoring algorithm implemented:
- Exact match: 100 points
- Starts with query: 80 points
- Contains query (word boundary): 60 points
- Contains query (anywhere): 40 points
- Fuzzy match: 20 points
- Bonus for library items (albums): +10 points
- Bonus for favorites: +5 points
- Secondary field matching (artist name in albums/tracks)
- Merged all results into single sorted list
- Type indicators in subtitle (e.g., "Artist • Album")
- [x] Smart Cross-Referencing
- Extracts unique artists from matched tracks/albums
- Filters to artists whose name contains query words (min 3 chars)
- Adds cross-referenced artists with lower relevance score (25)
- Avoids duplicates with direct search results
- [x] Past Searches Row
- Added SearchHistory table to database (schema v4)
- Saves successful searches (deduplicated, max 10)
- Displays as ActionChips in empty state
- Clicking chip performs that search
## Remaining Ideas (Lower Priority)
- **Library-only toggle:** Add switch to filter search to library items only (MA API supports this)
- **Voice search:** Add microphone button if platform supports speech recognition
- **Search suggestions:** Typeahead from library as user types
- **Quick actions:** Long-press result for play/queue options without navigating
- **Clear history in settings:** Add option to clear search history
## Files Modified
| File | Changes |
|------|---------|
| `lib/database/database.dart` | Added SearchHistory table, schema v4 |
| `lib/services/database_service.dart` | Added search history methods |
| `lib/screens/search_screen.dart` | Relevance scoring, cross-refs, history UI |
| `lib/l10n/app_en.arb` | Added localization strings |
+308 -3
View File
@@ -93,12 +93,99 @@ class SyncMetadata extends Table {
Set<Column> get primaryKey => {syncType};
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata])
/// Persisted playback state - survives app restarts and disconnects
class PlaybackState extends Table {
/// Always 'current' - single row table
TextColumn get id => text().withDefault(const Constant('current'))();
/// Selected player ID
TextColumn get playerId => text().nullable()();
/// Selected player name (for display if player unavailable)
TextColumn get playerName => text().nullable()();
/// Current track as JSON
TextColumn get currentTrackJson => text().nullable()();
/// Current position in seconds
RealColumn get positionSeconds => real().withDefault(const Constant(0.0))();
/// Whether playback was active
BoolColumn get isPlaying => boolean().withDefault(const Constant(false))();
/// When this state was saved
DateTimeColumn get savedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// Cached player list - for instant display on app resume
class CachedPlayers extends Table {
/// Player ID from Music Assistant
TextColumn get playerId => text()();
/// Player data as JSON
TextColumn get playerJson => text()();
/// Current track for this player as JSON (for mini player display)
TextColumn get currentTrackJson => text().nullable()();
/// When this was last updated
DateTimeColumn get lastUpdated => dateTime()();
@override
Set<Column> get primaryKey => {playerId};
}
/// Cached queue items for selected player
class CachedQueue extends Table {
/// Auto-incrementing ID for ordering
IntColumn get id => integer().autoIncrement()();
/// Player ID this queue belongs to
TextColumn get playerId => text()();
/// Queue item as JSON
TextColumn get itemJson => text()();
/// Position in queue
IntColumn get position => integer()();
}
/// Cached home row data for instant display on startup
class HomeRowCache extends Table {
/// Row type: 'recent_albums', 'discover_artists', 'discover_albums'
TextColumn get rowType => text()();
/// Serialized list of items as JSON array
TextColumn get itemsJson => text()();
/// When this was last updated
DateTimeColumn get lastUpdated => dateTime()();
@override
Set<Column> get primaryKey => {rowType};
}
/// Search history for quick access to recent searches
class SearchHistory extends Table {
/// Auto-incrementing ID
IntColumn get id => integer().autoIncrement()();
/// The search query
TextColumn get query => text()();
/// When the search was performed
DateTimeColumn get searchedAt => dateTime()();
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 4;
@override
MigrationStrategy get migration {
@@ -107,7 +194,20 @@ class AppDatabase extends _$AppDatabase {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
// Future migrations will go here
// Migration from v1 to v2: Add playback state, cached players, cached queue tables
if (from < 2) {
await m.createTable(playbackState);
await m.createTable(cachedPlayers);
await m.createTable(cachedQueue);
}
// Migration from v2 to v3: Add home row cache table
if (from < 3) {
await m.createTable(homeRowCache);
}
// Migration from v3 to v4: Add search history table
if (from < 4) {
await m.createTable(searchHistory);
}
},
);
}
@@ -327,4 +427,209 @@ class AppDatabase extends _$AppDatabase {
if (lastSync == null) return true;
return DateTime.now().difference(lastSync) > maxAge;
}
// ============================================
// Playback State Operations
// ============================================
/// Save current playback state
Future<void> savePlaybackState({
String? playerId,
String? playerName,
String? currentTrackJson,
double positionSeconds = 0.0,
bool isPlaying = false,
}) async {
await into(playbackState).insertOnConflictUpdate(PlaybackStateCompanion.insert(
id: const Value('current'),
playerId: Value(playerId),
playerName: Value(playerName),
currentTrackJson: Value(currentTrackJson),
positionSeconds: Value(positionSeconds),
isPlaying: Value(isPlaying),
savedAt: DateTime.now(),
));
}
/// Get saved playback state
Future<PlaybackStateData?> getPlaybackState() async {
return (select(playbackState)..where((p) => p.id.equals('current')))
.getSingleOrNull();
}
/// Clear playback state
Future<void> clearPlaybackState() async {
await delete(playbackState).go();
}
// ============================================
// Cached Players Operations
// ============================================
/// Cache a player with its current track
Future<void> cachePlayer({
required String playerId,
required String playerJson,
String? currentTrackJson,
}) async {
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
playerId: playerId,
playerJson: playerJson,
currentTrackJson: Value(currentTrackJson),
lastUpdated: DateTime.now(),
));
}
/// Cache multiple players at once
Future<void> cachePlayers(List<Map<String, dynamic>> players) async {
await transaction(() async {
for (final player in players) {
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
playerId: player['playerId'] as String,
playerJson: player['playerJson'] as String,
currentTrackJson: Value(player['currentTrackJson'] as String?),
lastUpdated: DateTime.now(),
));
}
});
}
/// Get all cached players
Future<List<CachedPlayer>> getCachedPlayers() async {
return (select(cachedPlayers)
..orderBy([(p) => OrderingTerm.desc(p.lastUpdated)]))
.get();
}
/// Get a specific cached player
Future<CachedPlayer?> getCachedPlayer(String playerId) async {
return (select(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
.getSingleOrNull();
}
/// Update cached track for a player
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) async {
await (update(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
.write(CachedPlayersCompanion(
currentTrackJson: Value(trackJson),
lastUpdated: Value(DateTime.now()),
));
}
/// Clear all cached players
Future<void> clearCachedPlayers() async {
await delete(cachedPlayers).go();
}
// ============================================
// Cached Queue Operations
// ============================================
/// Save queue for a player
Future<void> saveQueue(String playerId, List<String> itemJsonList) async {
await transaction(() async {
// Clear existing queue for this player
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
// Insert new queue items
for (var i = 0; i < itemJsonList.length; i++) {
await into(cachedQueue).insert(CachedQueueCompanion.insert(
playerId: playerId,
itemJson: itemJsonList[i],
position: i,
));
}
});
}
/// Get cached queue for a player
Future<List<CachedQueueData>> getCachedQueue(String playerId) async {
return (select(cachedQueue)
..where((q) => q.playerId.equals(playerId))
..orderBy([(q) => OrderingTerm.asc(q.position)]))
.get();
}
/// Clear queue for a player
Future<void> clearCachedQueue(String playerId) async {
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
}
/// Clear all cached queues
Future<void> clearAllCachedQueues() async {
await delete(cachedQueue).go();
}
// ============================================
// Home Row Cache Operations
// ============================================
/// Save home row data
Future<void> saveHomeRowCache(String rowType, String itemsJson) async {
await into(homeRowCache).insertOnConflictUpdate(
HomeRowCacheCompanion(
rowType: Value(rowType),
itemsJson: Value(itemsJson),
lastUpdated: Value(DateTime.now()),
),
);
}
/// Get cached home row data
Future<HomeRowCacheData?> getHomeRowCache(String rowType) async {
return (select(homeRowCache)..where((h) => h.rowType.equals(rowType)))
.getSingleOrNull();
}
/// Clear all home row cache
Future<void> clearHomeRowCache() async {
await delete(homeRowCache).go();
}
// ============================================
// Search History Operations
// ============================================
/// Save a search query to history (deduplicates and limits to 10)
Future<void> saveSearchQuery(String query) async {
final trimmedQuery = query.trim();
if (trimmedQuery.isEmpty) return;
await transaction(() async {
// Delete existing entry with same query (case-insensitive)
await (delete(searchHistory)
..where((s) => s.query.lower().equals(trimmedQuery.toLowerCase())))
.go();
// Insert the new search
await into(searchHistory).insert(SearchHistoryCompanion.insert(
query: trimmedQuery,
searchedAt: DateTime.now(),
));
// Keep only the 10 most recent searches
final allSearches = await (select(searchHistory)
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)]))
.get();
if (allSearches.length > 10) {
final toDelete = allSearches.skip(10).map((s) => s.id).toList();
await (delete(searchHistory)..where((s) => s.id.isIn(toDelete))).go();
}
});
}
/// Get recent search queries (up to 10, most recent first)
Future<List<String>> getRecentSearches() async {
final searches = await (select(searchHistory)
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)])
..limit(10))
.get();
return searches.map((s) => s.query).toList();
}
/// Clear all search history
Future<void> clearSearchHistory() async {
await delete(searchHistory).go();
}
}
+22 -1
View File
@@ -126,6 +126,7 @@
"playing": "Spiele {name}",
"playingTrack": "Spiele Song",
"nowPlaying": "Wird gespielt",
"markedAsFinished": "{name} als beendet markiert",
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
@@ -195,6 +196,7 @@
"language": "Sprache",
"english": "Englisch",
"german": "Deutsch",
"spanish": "Spanisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren",
@@ -266,5 +268,24 @@
"startRadioOn": "{name} Radio starten auf...",
"book": "Buch",
"audiobookSingular": "Hörbuch",
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}"
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
"accentColor": "Akzentfarbe",
"players": "Geräte",
"preferLocalPlayer": "Lokales Gerät bevorzugen",
"preferLocalPlayerDescription": "Dieses Gerät immer zuerst auswählen, wenn verfügbar",
"smartSortPlayers": "Intelligente Sortierung",
"smartSortPlayersDescription": "Nach Status sortieren (spielt, an, aus) statt alphabetisch",
"playerStateUnavailable": "Nicht verfügbar",
"playerStateOff": "Aus",
"playerStateIdle": "Inaktiv",
"playerStateExternalSource": "Externe Quelle",
"playerSelected": "Ausgewählt",
"actionQueuedForSync": "Wird synchronisiert wenn online",
"pendingOfflineActions": "{count} ausstehende Synchronisierung",
"hintsAndTips": "Tipps & Hinweise",
"showHints": "Hinweise anzeigen",
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
"holdToSync": "Gerät gedrückt halten zum Synchronisieren"
}
+171 -1
View File
@@ -261,6 +261,26 @@
"description": "Search hint text"
},
"recentSearches": "Recent Searches",
"@recentSearches": {
"description": "Label for recent search history section"
},
"clearSearchHistory": "Clear Search History",
"@clearSearchHistory": {
"description": "Button to clear search history"
},
"searchHistoryCleared": "Search history cleared",
"@searchHistoryCleared": {
"description": "Confirmation message after clearing search history"
},
"libraryOnly": "Library only",
"@libraryOnly": {
"description": "Toggle to search only in library"
},
"retry": "Retry",
"@retry": {
"description": "Retry button label"
@@ -610,6 +630,11 @@
"description": "Radio button label"
},
"addedToQueue": "Added to queue",
"@addedToQueue": {
"description": "Confirmation when item added to queue"
},
"inLibrary": "In Library",
"@inLibrary": {
"description": "In library section title"
@@ -635,6 +660,11 @@
"description": "Status when playing track"
},
"nowPlaying": "Now playing",
"@nowPlaying": {
"description": "Label shown when device selector is open to indicate current player"
},
"markedAsFinished": "{name} marked as finished",
"@markedAsFinished": {
"description": "Audiobook marked finished",
@@ -970,6 +1000,11 @@
"description": "German language"
},
"spanish": "Spanish",
"@spanish": {
"description": "Spanish language"
},
"noTracksInPlaylist": "No tracks in playlist",
"sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year",
@@ -1068,7 +1103,7 @@
"description": "Hint for Last.fm API key field"
},
"swipeToSwitchDevice": "Swipe to switch device",
"swipeToSwitchDevice": "Swipe to change player",
"@swipeToSwitchDevice": {
"description": "Hint for device switching gesture"
},
@@ -1123,6 +1158,16 @@
"description": "Singular form of audiobook"
},
"albumSingular": "Album",
"@albumSingular": {
"description": "Singular form of album for search type indicator"
},
"trackSingular": "Track",
"@trackSingular": {
"description": "Singular form of track for search type indicator"
},
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
"@trackCount": {
"description": "Track count with plural",
@@ -1131,5 +1176,130 @@
"type": "int"
}
}
},
"materialYouDescription": "Use system colors (Android 12+)",
"@materialYouDescription": {
"description": "Description for Material You theme option"
},
"accentColor": "Accent Color",
"@accentColor": {
"description": "Accent color selection label"
},
"players": "Players",
"@players": {
"description": "Players settings section title"
},
"preferLocalPlayer": "Prefer Local Player",
"@preferLocalPlayer": {
"description": "Setting to always prefer local player"
},
"preferLocalPlayerDescription": "Always select this device first when available",
"@preferLocalPlayerDescription": {
"description": "Description for prefer local player setting"
},
"smartSortPlayers": "Smart Sort",
"@smartSortPlayers": {
"description": "Setting to sort players by status"
},
"smartSortPlayersDescription": "Sort by status (playing, on, off) instead of alphabetically",
"@smartSortPlayersDescription": {
"description": "Description for smart sort players setting"
},
"playerStateUnavailable": "Unavailable",
"@playerStateUnavailable": {
"description": "Player state when unavailable"
},
"playerStateOff": "Off",
"@playerStateOff": {
"description": "Player state when powered off"
},
"playerStateIdle": "Idle",
"@playerStateIdle": {
"description": "Player state when idle"
},
"playerStateExternalSource": "External Source",
"@playerStateExternalSource": {
"description": "Player state when playing from external source (optical, Spotify, etc.)"
},
"playerSelected": "Selected",
"@playerSelected": {
"description": "Label for selected player"
},
"actionQueuedForSync": "Will sync when online",
"@actionQueuedForSync": {
"description": "Message when action is queued for sync while offline"
},
"pendingOfflineActions": "{count} pending sync",
"@pendingOfflineActions": {
"description": "Shows number of pending offline actions",
"placeholders": {
"count": {
"type": "int"
}
}
},
"hintsAndTips": "Hints & Tips",
"@hintsAndTips": {
"description": "Settings section title for hints"
},
"showHints": "Show Hints",
"@showHints": {
"description": "Toggle label for showing hints"
},
"showHintsDescription": "Display helpful tips for discovering features",
"@showHintsDescription": {
"description": "Description for show hints toggle"
},
"pullToSelectPlayers": "Pull to select players",
"@pullToSelectPlayers": {
"description": "Hint shown when mini player bounces"
},
"holdToSync": "Tap and hold a player to sync",
"@holdToSync": {
"description": "Hint for long-press to sync players"
},
"selectPlayerHint": "Choose a player, or dismiss by swiping down",
"@selectPlayerHint": {
"description": "First-time hint for selecting a player in device selector"
},
"welcomeToEnsemble": "Welcome to Ensemble",
"@welcomeToEnsemble": {
"description": "Welcome message title for onboarding"
},
"welcomeMessage": "By default your phone is the selected player.\nPull the mini player down to select another player.",
"@welcomeMessage": {
"description": "Welcome message body explaining how to select players"
},
"skip": "Skip",
"@skip": {
"description": "Button to skip onboarding or hints"
},
"dismissPlayerHint": "Swipe down, tap outside, or press back to return",
"@dismissPlayerHint": {
"description": "Hint explaining how to dismiss the player selector"
}
}
+487
View File
@@ -0,0 +1,487 @@
{
"@@locale": "es",
"appTitle": "Ensemble",
"connecting": "Conectando...",
"connected": "Conectado",
"disconnected": "Desconectado",
"serverAddress": "Dirección del servidor",
"serverAddressHint": "ej., music.example.com o 192.168.1.100",
"yourName": "Tu nombre",
"yourFirstName": "Tu nombre de pila",
"portOptional": "Puerto (Opcional)",
"portHint": "ej., 8095 o dejar en blanco",
"portDescription": "Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.",
"username": "Usuario",
"password": "Contraseña",
"detectAndConnect": "Detectar y conectar",
"connect": "Conectar",
"disconnect": "Desconectar",
"authServerUrlOptional": "URL del servidor de autenticación (Opcional)",
"authServerUrlHint": "ej., auth.example.com (si es diferente del servidor)",
"authServerUrlDescription": "Dejar vacío si la autenticación está en el mismo servidor",
"detectedAuthType": "Detectado: {authType}",
"@detectedAuthType": {
"placeholders": {
"authType": {
"type": "String"
}
}
},
"serverRequiresAuth": "Este servidor requiere autenticación",
"tailscaleVpnConnection": "Conexión VPN Tailscale",
"unencryptedConnection": "Conexión sin cifrar",
"usingHttpOverTailscale": "Usando HTTP sobre Tailscale (túnel cifrado)",
"httpsFailedUsingHttp": "HTTPS falló, usando HTTP como alternativa",
"httpNotEncrypted": "Conexión HTTP - los datos no están cifrados",
"pleaseEnterServerAddress": "Por favor, introduce la dirección de tu servidor Music Assistant",
"pleaseEnterName": "Por favor, introduce tu nombre",
"pleaseEnterValidPort": "Por favor, introduce un número de puerto válido (1-65535)",
"pleaseEnterCredentials": "Por favor, introduce usuario y contraseña",
"authFailed": "Autenticación fallida. Por favor, verifica tus credenciales.",
"maLoginFailed": "Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.",
"connectionFailed": "No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.",
"detectingAuth": "Detectando autenticación...",
"cannotDetermineAuth": "No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.",
"noAuthentication": "Sin autenticación",
"httpBasicAuth": "Autenticación HTTP Basic",
"authelia": "Authelia",
"musicAssistantLogin": "Inicio de sesión de Music Assistant",
"pressBackToMinimize": "Presiona atrás de nuevo para minimizar",
"recentlyPlayed": "Reproducido recientemente",
"discoverArtists": "Descubrir artistas",
"discoverAlbums": "Descubrir álbumes",
"continueListening": "Continuar escuchando",
"discoverAudiobooks": "Descubrir audiolibros",
"discoverSeries": "Descubrir series",
"favoriteAlbums": "Álbumes favoritos",
"favoriteArtists": "Artistas favoritos",
"favoriteTracks": "Canciones favoritas",
"searchMusic": "Buscar música...",
"searchForContent": "Buscar artistas, álbumes o canciones",
"retry": "Reintentar",
"all": "Todo",
"artists": "Artistas",
"albums": "Álbumes",
"tracks": "Canciones",
"playlists": "Listas de reproducción",
"audiobooks": "Audiolibros",
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
"queue": "Cola",
"playerQueue": "Cola de {playerName}",
"@playerQueue": {
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"noPlayerSelected": "Ningún reproductor seleccionado",
"undo": "Deshacer",
"removedItem": "Eliminado {itemName}",
"@removedItem": {
"placeholders": {
"itemName": {
"type": "String"
}
}
},
"settings": "Ajustes",
"debugLogs": "Registros de depuración",
"themeMode": "Modo de tema",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema",
"pullToRefresh": "Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios",
"viewDebugLogs": "Ver registros de depuración",
"viewAllPlayers": "Ver todos los reproductores",
"copyLogs": "Copiar registros",
"clearLogs": "Borrar registros",
"copyList": "Copiar lista",
"close": "Cerrar",
"allPlayersCount": "Todos los reproductores ({count})",
"@allPlayersCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"errorLoadingPlayers": "Error al cargar reproductores: {error}",
"@errorLoadingPlayers": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"logsCopied": "Registros copiados al portapapeles",
"logsCleared": "Registros borrados",
"playerListCopied": "Lista de reproductores copiada al portapapeles",
"noLogsYet": "Aún no hay registros",
"infoPlus": "Info+",
"warnings": "Advertencias",
"errors": "Errores",
"showingEntries": "Mostrando {filtered} de {total} entradas",
"@showingEntries": {
"placeholders": {
"filtered": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"thisDevice": "Este dispositivo",
"ghostPlayer": "Reproductor fantasma (duplicado)",
"unavailableCorrupt": "No disponible/Corrupto",
"playerId": "ID: {id}",
"@playerId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"playerInfo": "Disponible: {available} | Proveedor: {provider}",
"@playerInfo": {
"placeholders": {
"available": {
"type": "String"
},
"provider": {
"type": "String"
}
}
},
"shareBugReport": "Compartir informe de errores",
"moreOptions": "Más opciones",
"failedToUpdateFavorite": "Error al actualizar favorito: {error}",
"@failedToUpdateFavorite": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"noPlayersAvailable": "No hay reproductores disponibles",
"albumAddedToQueue": "Álbum añadido a la cola",
"tracksAddedToQueue": "Canciones añadidas a la cola",
"play": "Reproducir",
"noPlayersFound": "No se encontraron reproductores",
"startingRadio": "Iniciando radio de {name}",
"@startingRadio": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToStartRadio": "Error al iniciar radio: {error}",
"@failedToStartRadio": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"startingRadioOnPlayer": "Iniciando radio de {name} en {playerName}",
"@startingRadioOnPlayer": {
"placeholders": {
"name": {
"type": "String"
},
"playerName": {
"type": "String"
}
}
},
"addedRadioToQueue": "Radio de {name} añadida a la cola",
"@addedRadioToQueue": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToAddToQueue": "Error al añadir a la cola: {error}",
"@failedToAddToQueue": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"radio": "Radio",
"inLibrary": "En la biblioteca",
"noAlbumsFound": "No se encontraron álbumes",
"playing": "Reproduciendo {name}",
"@playing": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"playingTrack": "Reproduciendo canción",
"nowPlaying": "Reproduciendo ahora",
"markedAsFinished": "{name} marcado como terminado",
"@markedAsFinished": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkFinished": "Error al marcar como terminado: {error}",
"@failedToMarkFinished": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markedAsUnplayed": "{name} marcado como no reproducido",
"@markedAsUnplayed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkUnplayed": "Error al marcar como no reproducido: {error}",
"@failedToMarkUnplayed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToPlay": "Error al reproducir: {error}",
"@failedToPlay": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markAsFinished": "Marcar como terminado",
"markAsUnplayed": "Marcar como no reproducido",
"byAuthor": "Por {author}",
"@byAuthor": {
"placeholders": {
"author": {
"type": "String"
}
}
},
"audiobookCount": "{count} audiolibro(s)",
"@audiobookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"loading": "Cargando...",
"bookCount": "{count} libro(s)",
"@bookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"books": "Libros",
"noFavoriteAudiobooks": "No hay audiolibros favoritos",
"tapHeartAudiobook": "Toca el corazón en un audiolibro para añadirlo a favoritos",
"noAudiobooks": "No hay audiolibros",
"addAudiobooksHint": "Añade audiolibros a tu biblioteca para verlos aquí",
"noFavoriteArtists": "No hay artistas favoritos",
"tapHeartArtist": "Toca el corazón en un artista para añadirlo a favoritos",
"noFavoriteAlbums": "No hay álbumes favoritos",
"tapHeartAlbum": "Toca el corazón en un álbum para añadirlo a favoritos",
"noFavoritePlaylists": "No hay listas de reproducción favoritas",
"tapHeartPlaylist": "Toca el corazón en una lista para añadirla a favoritos",
"noFavoriteTracks": "No hay canciones favoritas",
"longPressTrackHint": "Mantén presionada una canción y toca el corazón para añadirla a favoritos",
"loadSeries": "Cargar series",
"notConnected": "No conectado a Music Assistant",
"notConnectedTitle": "No conectado",
"connectHint": "Conéctate a tu servidor Music Assistant para empezar a escuchar",
"configureServer": "Configurar servidor",
"noArtistsFound": "No se encontraron artistas",
"noTracksFound": "No se encontraron canciones",
"noPlaylistsFound": "No se encontraron listas de reproducción",
"queueIsEmpty": "La cola está vacía",
"noResultsFound": "No se encontraron resultados",
"refresh": "Actualizar",
"debugConsole": "Consola de depuración",
"copy": "Copiar",
"clear": "Borrar",
"noLogsToCopy": "No hay registros para copiar",
"noDebugLogsYet": "Aún no hay registros de depuración. Intenta detectar autenticación.",
"showDebug": "Mostrar depuración",
"hideDebug": "Ocultar depuración",
"chapters": "Capítulos",
"noChapters": "Sin capítulos",
"noChapterInfo": "Este audiolibro no tiene información de capítulos",
"errorSeeking": "Error al buscar: {error}",
"@errorSeeking": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"error": "Error: {error}",
"@error": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"home": "Inicio",
"library": "Biblioteca",
"homeScreen": "Pantalla de inicio",
"metadataApis": "APIs de metadatos",
"theme": "Tema",
"materialYou": "Material You",
"adaptiveTheme": "Tema adaptativo",
"language": "Idioma",
"english": "Inglés",
"german": "Alemán",
"spanish": "Español",
"noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año",
"sortBySeriesOrder": "Ordenar por orden de serie",
"listView": "Vista de lista",
"gridView": "Vista de cuadrícula",
"noBooksInSeries": "No se encontraron libros en esta serie",
"artist": "Artista",
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
"showFavoriteAlbums": "Mostrar una fila de tus álbumes favoritos",
"showFavoriteArtists": "Mostrar una fila de tus artistas favoritos",
"showFavoriteTracks": "Mostrar una fila de tus canciones favoritas",
"extractColorsFromArtwork": "Extraer colores de las portadas de álbumes y artistas",
"chooseHomeScreenRows": "Elige qué filas mostrar en la pantalla de inicio",
"addedToFavorites": "Añadido a favoritos",
"removedFromFavorites": "Eliminado de favoritos",
"unknown": "Desconocido",
"noUpcomingTracks": "No hay canciones próximas",
"showAll": "Mostrar todo",
"showFavoritesOnly": "Mostrar solo favoritos",
"changeView": "Cambiar vista",
"authors": "Autores",
"series": "Series",
"shows": "Programas",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Soporte de podcasts próximamente",
"playlist": "Lista de reproducción",
"connectionError": "Error de conexión",
"twoColumnGrid": "Cuadrícula de 2 columnas",
"threeColumnGrid": "Cuadrícula de 3 columnas",
"fromProviders": "De proveedores",
"resume": "Continuar",
"about": "Acerca de",
"inProgress": "En progreso",
"narratedBy": "Narrado por {narrators}",
"@narratedBy": {
"placeholders": {
"narrators": {
"type": "String"
}
}
},
"unknownNarrator": "Narrador desconocido",
"unknownAuthor": "Autor desconocido",
"loadingChapters": "Cargando capítulos...",
"noChapterInfoAvailable": "No hay información de capítulos disponible",
"percentComplete": "{percent}% completado",
"@percentComplete": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"theAudioDbApiKey": "Clave API de TheAudioDB",
"theAudioDbApiKeyHint": "Usa \"2\" para nivel gratuito o clave premium",
"audiobookLibraries": "Bibliotecas de audiolibros",
"chooseAudiobookLibraries": "Elige qué bibliotecas de Audiobookshelf incluir",
"unknownLibrary": "Biblioteca desconocida",
"noSeriesFound": "No se encontraron series",
"ensembleBugReport": "Informe de errores de Ensemble",
"byOwner": "Por {owner}",
"@byOwner": {
"placeholders": {
"owner": {
"type": "String"
}
}
},
"noSeriesAvailable": "No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.",
"pullToLoadSeries": "Desliza hacia abajo para cargar series\ndesde Music Assistant",
"search": "Buscar",
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
"lastFmApiKey": "Clave API de Last.fm",
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
"swipeToSwitchDevice": "Desliza para cambiar dispositivo",
"chapterNumber": "Capítulo {number}",
"@chapterNumber": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"pcmAudio": "Audio PCM",
"playOn": "Reproducir en...",
"addAlbumToQueueOn": "Añadir álbum a la cola en...",
"addToQueueOn": "Añadir a la cola en...",
"startRadioOn": "Iniciar radio de {name} en...",
"@startRadioOn": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"book": "libro",
"audiobookSingular": "audiolibro",
"trackCount": "{count} {count, plural, =1{canción} other{canciones}}",
"@trackCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"materialYouDescription": "Usar colores del sistema (Android 12+)",
"accentColor": "Color de acento",
"players": "Dispositivos",
"preferLocalPlayer": "Preferir dispositivo local",
"preferLocalPlayerDescription": "Seleccionar siempre este dispositivo primero cuando esté disponible",
"smartSortPlayers": "Orden inteligente",
"smartSortPlayersDescription": "Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente",
"playerStateUnavailable": "No disponible",
"playerStateOff": "Apagado",
"playerStateIdle": "Inactivo",
"playerStateExternalSource": "Fuente Externa",
"playerSelected": "Seleccionado",
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
"pendingOfflineActions": "{count} sincronización pendiente",
"hintsAndTips": "Consejos y Sugerencias",
"showHints": "Mostrar Sugerencias",
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado un dispositivo para sincronizar"
}
+67 -9
View File
@@ -172,11 +172,20 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
@override
Future<bool> didPopRoute() async {
// Intercept back button at app level - runs BEFORE Navigator processes it
// If player is expanded, collapse it and consume the back gesture
// Priority order: device list > expanded player > normal navigation
// Device list has highest priority - dismiss it first
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return true; // We handled it, don't let Navigator process it
}
// Then check expanded player
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
return true; // We handled it, don't let Navigator process it
}
// Let Navigator handle the back gesture normally
return super.didPopRoute();
}
@@ -221,9 +230,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
lightColorScheme = light;
darkColorScheme = dark;
} else {
// Use brand color schemes
lightColorScheme = brandLightColorScheme;
darkColorScheme = brandDarkColorScheme;
// Use custom color from theme provider
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
darkColorScheme = generateDarkColorScheme(themeProvider.customColor);
}
return SystemUIWrapper(
@@ -339,7 +348,16 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
}
}
/// Startup widget that checks if user is logged in and auto-connects
/// Startup widget that checks if user is logged in and auto-connects.
///
/// CRITICAL: This widget is the single source of truth for the startup transition.
/// It loads settings FIRST (before connection), then waits for the appropriate
/// conditions before showing HomeScreen. This eliminates the race condition between
/// AppStartup and GlobalPlayerOverlay that caused the home screen flash.
///
/// For first-time users: Holds dark screen until connected + player selected,
/// so the welcome overlay can appear seamlessly.
/// For returning users: Transitions as soon as connection is established.
class AppStartup extends StatefulWidget {
const AppStartup({super.key});
@@ -352,9 +370,32 @@ class _AppStartupState extends State<AppStartup> {
String? _savedServerUrl;
bool _connectionAttempted = false;
// Welcome system state - loaded FIRST before connection check
bool _settingsLoaded = false;
bool _hasCompletedOnboarding = true; // Default to true (no welcome needed)
@override
void initState() {
super.initState();
_loadSettingsFirst();
}
/// Load settings BEFORE checking connection.
/// This ensures we know if this is a first-time user before transitioning.
Future<void> _loadSettingsFirst() async {
// Load onboarding state first - this is critical for determining
// when to transition to HomeScreen
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!mounted) return;
setState(() {
_settingsLoaded = true;
});
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
// Now check connection
_checkAndConnect();
}
@@ -379,14 +420,30 @@ class _AppStartupState extends State<AppStartup> {
// Just wait for it to complete or timeout
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
// For first-time users, we need to wait for both connection AND player selection
// so the welcome overlay can appear immediately without home screen flash.
// For returning users, just wait for connection.
final needsWelcome = !_hasCompletedOnboarding;
// Give the provider time to connect (it restores credentials and connects in _initialize)
// Check connection state periodically
for (var i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
break;
if (needsWelcome) {
// First-time user: wait for connection + player selection
if (provider.isConnected && provider.selectedPlayer != null) {
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
break;
}
} else {
// Returning user: just wait for connection
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established');
break;
}
}
if (provider.connectionState == MAConnectionState.error) {
_logger.log('🚀 AppStartup: Connection failed with error');
break;
@@ -413,7 +470,8 @@ class _AppStartupState extends State<AppStartup> {
@override
Widget build(BuildContext context) {
// Show loading while checking settings or connecting
if (!_connectionAttempted || _isConnecting) {
// CRITICAL: Don't transition until settings are loaded - this prevents the race
if (!_settingsLoaded || !_connectionAttempted || _isConnecting) {
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
body: Center(
+2 -1
View File
@@ -110,7 +110,8 @@ class MediaItem {
'media_type': mediaType.name,
if (sortName != null) 'sort_name': sortName,
if (uri != null) 'uri': uri,
if (providerMappings != null) 'provider_mappings': providerMappings,
if (providerMappings != null)
'provider_mappings': providerMappings!.map((m) => m.toJson()).toList(),
if (metadata != null) 'metadata': metadata,
if (favorite != null) 'favorite': favorite,
if (position != null) 'position': position,
+80
View File
@@ -12,6 +12,10 @@ class Player {
final bool? volumeMuted;
final double? elapsedTime; // Seconds elapsed in current track
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
final List<String>? groupMembers; // List of player IDs in sync group (includes self)
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
Player({
required this.playerId,
@@ -25,6 +29,10 @@ class Player {
this.volumeMuted,
this.elapsedTime,
this.elapsedTimeLastUpdated,
this.groupMembers,
this.syncedTo,
this.activeSource,
this.isExternalSource = false,
});
/// Create a copy of this Player with some fields replaced
@@ -40,6 +48,10 @@ class Player {
bool? volumeMuted,
double? elapsedTime,
double? elapsedTimeLastUpdated,
List<String>? groupMembers,
String? syncedTo,
String? activeSource,
bool? isExternalSource,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -53,6 +65,10 @@ class Player {
volumeMuted: volumeMuted ?? this.volumeMuted,
elapsedTime: elapsedTime ?? this.elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
groupMembers: groupMembers ?? this.groupMembers,
syncedTo: syncedTo ?? this.syncedTo,
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
);
}
@@ -61,6 +77,12 @@ class Player {
bool get isMuted => volumeMuted ?? false;
int get volume => volumeLevel ?? 0;
// Group properties
// A player is grouped if it's a leader with members OR a child synced to another
bool get isGrouped => (groupMembers != null && groupMembers!.length > 1) || syncedTo != null;
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
bool get isGroupChild => syncedTo != null;
// Track when this Player object was created (for local interpolation fallback)
static final Map<String, double> _playerCreationTimes = {};
@@ -164,6 +186,56 @@ class Player {
elapsedTime ??= topLevelElapsedTime;
elapsedTimeLastUpdated ??= topLevelLastUpdated;
// Parse group members - MA returns this as a list of player IDs
final groupMembersList = json['group_members'] as List<dynamic>?;
final groupMembers = groupMembersList?.map((e) => e.toString()).toList();
// Parse synced_to - the player ID this player is synced to
final syncedTo = json['synced_to'] as String?;
// Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.)
// External sources have non-MA URIs and often media_type='unknown'
bool isExternalSource = false;
if (json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?;
// External source indicators:
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
if (uri != null) {
final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /)
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
}
// Also check media_type - 'unknown' often indicates external source
if (!isExternalSource && mediaType == 'unknown') {
// If media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true;
}
}
}
}
return Player(
playerId: json['player_id'] as String,
name: json['name'] as String,
@@ -176,6 +248,10 @@ class Player {
volumeMuted: json['volume_muted'] as bool?,
elapsedTime: elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
groupMembers: groupMembers,
syncedTo: syncedTo,
activeSource: activeSource,
isExternalSource: isExternalSource,
);
}
@@ -192,6 +268,10 @@ class Player {
if (volumeMuted != null) 'volume_muted': volumeMuted,
if (elapsedTime != null) 'elapsed_time': elapsedTime,
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
if (groupMembers != null) 'group_members': groupMembers,
if (syncedTo != null) 'synced_to': syncedTo,
if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource,
};
}
}
+9 -1
View File
@@ -189,7 +189,9 @@ class ConnectionProvider with ChangeNotifier {
_logger.log('✅ MA authentication successful');
onAuthenticated?.call();
} else if (state == MAConnectionState.disconnected) {
_cacheService.clearAll();
// DON'T clear caches on disconnect - keep showing cached data
// This allows instant UI display when app resumes or reconnects
// Caches will be refreshed when connection is restored
onDisconnected?.call();
}
},
@@ -215,6 +217,12 @@ class ConnectionProvider with ChangeNotifier {
Future<void> disconnect() async {
await _api?.disconnect();
_connectionState = MAConnectionState.disconnected;
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
notifyListeners();
}
/// Clear all caches and state (for logout or server change)
void clearCachesOnLogout() {
_cacheService.clearAll();
notifyListeners();
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
+134 -65
View File
@@ -16,11 +16,14 @@ import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget {
final Album album;
final String? heroTagSuffix;
/// Initial image URL from the source (e.g., AlbumCard) for seamless hero animation
final String? initialImageUrl;
const AlbumDetailsScreen({
super.key,
required this.album,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
@@ -37,6 +40,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
int? _expandedTrackIndex;
bool _isDescriptionExpanded = false;
String? _albumDescription;
Album? _freshAlbum; // Full album data with image metadata
/// Get the best album data available (fresh with images, or widget.album as fallback)
Album get _displayAlbum => _freshAlbum ?? widget.album;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -46,29 +53,31 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
_isFavorite = widget.album.favorite ?? false;
_loadTracks();
_loadAlbumDescription();
_refreshFavoriteStatus();
// CRITICAL FIX: Defer color extraction until after the transition completes
// This prevents expensive palette extraction during hero animation
// Wait for the first frame to be painted, then extract colors
// CRITICAL FIX: Defer both fresh data loading AND color extraction until
// after the hero animation completes. This prevents:
// 1. setState with new image URL during animation → grey icon flash
// 2. Expensive palette extraction blocking animation frames
WidgetsBinding.instance.addPostFrameCallback((_) {
// Add small delay to ensure transition is complete
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadFreshAlbumData();
_extractColors();
}
});
});
}
Future<void> _refreshFavoriteStatus() async {
/// Load fresh album data from API to get full metadata including images
Future<void> _loadFreshAlbumData() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
// Need a URI to fetch fresh album data
final albumUri = widget.album.uri;
if (albumUri == null || albumUri.isEmpty) {
_logger.log('Cannot refresh favorite status: album has no URI');
_logger.log('Cannot load fresh album: album has no URI');
return;
}
@@ -76,17 +85,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
if (freshAlbum != null && mounted) {
setState(() {
_freshAlbum = freshAlbum;
_isFavorite = freshAlbum.favorite ?? false;
});
// Re-extract colors now that we have fresh album with images
_extractColors();
}
} catch (e) {
_logger.log('Error refreshing favorite status: $e');
_logger.log('Error loading fresh album data: $e');
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
final imageUrl = maProvider.getImageUrl(_displayAlbum, size: 512);
if (imageUrl == null) return;
@@ -108,10 +120,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
// For adding: use the actual provider and itemId from provider_mappings
@@ -132,17 +144,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// For removing: need the library_item_id (numeric)
// When provider is "library", itemId is the library ID as a string
// Otherwise, we need to find it from provider_mappings
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
// Find the library mapping
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
@@ -158,25 +171,34 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('album', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
setState(() {
_isFavorite = newState;
});
if (success) {
// Optimistically update UI regardless of online/offline state
setState(() {
_isFavorite = newState;
});
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling favorite: $e');
@@ -196,11 +218,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
final currentFavorite = track.favorite ?? false;
try {
bool success;
if (currentFavorite) {
// Remove from favorites - need library_item_id
int? libraryItemId;
@@ -217,7 +239,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
if (libraryItemId != null) {
await maProvider.api!.removeFromFavorites('track', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'track',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
} else {
// Add to favorites
@@ -236,33 +263,42 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
actualItemId = mapping.itemId;
}
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'track',
itemId: actualItemId,
provider: actualProvider,
);
}
// Update local state - create new track with toggled favorite
setState(() {
_tracks[trackIndex] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
uri: track.uri,
favorite: !currentFavorite,
artists: track.artists,
album: track.album,
duration: track.duration,
providerMappings: track.providerMappings,
);
});
if (success) {
// Optimistically update local state
setState(() {
_tracks[trackIndex] = Track(
itemId: track.itemId,
provider: track.provider,
name: track.name,
uri: track.uri,
favorite: !currentFavorite,
artists: track.artists,
album: track.album,
duration: track.duration,
providerMappings: track.providerMappings,
);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling track favorite: $e');
@@ -279,16 +315,46 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _loadTracks() async {
final provider = context.read<MusicAssistantProvider>();
final tracks = await provider.getAlbumTracksWithCache(
widget.album.provider,
widget.album.itemId,
);
final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
if (mounted) {
setState(() {
_tracks = tracks;
_isLoading = false;
});
// 1. Show cached data immediately (if available)
final cachedTracks = provider.getCachedAlbumTracks(cacheKey);
if (cachedTracks != null && cachedTracks.isNotEmpty) {
if (mounted) {
setState(() {
_tracks = cachedTracks;
_isLoading = false;
});
}
}
// 2. Fetch fresh data in background (silent refresh)
try {
final freshTracks = await provider.getAlbumTracksWithCache(
widget.album.provider,
widget.album.itemId,
forceRefresh: cachedTracks != null, // Force refresh if we had cache
);
// 3. Update if we got different data
if (mounted && freshTracks.isNotEmpty) {
final tracksChanged = _tracks.length != freshTracks.length ||
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
_tracks.first.itemId != freshTracks.first.itemId);
if (tracksChanged || _tracks.isEmpty) {
setState(() {
_tracks = freshTracks;
_isLoading = false;
});
}
}
} catch (e) {
_logger.log('⚠️ Background refresh failed: $e');
}
// Ensure loading is false even if everything failed
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@@ -595,9 +661,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
// Only rebuild when specific properties change, not on every provider update
final imageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getImageUrl(widget.album, size: 512),
// Use _displayAlbum which has fresh data with images if available
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
(provider) => provider.getImageUrl(_displayAlbum, size: 512),
);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
+96 -37
View File
@@ -155,10 +155,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
String actualProvider = widget.artist.provider;
@@ -177,7 +177,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'artist',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
@@ -199,24 +203,32 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
setState(() {
_isFavorite = newState;
});
if (success) {
setState(() {
_isFavorite = newState;
});
maProvider.invalidateHomeCache();
maProvider.invalidateHomeCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling artist favorite: $e');
@@ -351,9 +363,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return;
}
final api = maProvider.api;
if (api == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
return;
}
try {
// For adding to queue with radio mode, we use radio_mode but with 'add' option
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
await api.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -427,20 +447,51 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _loadArtistAlbums() async {
final provider = context.read<MusicAssistantProvider>();
// Use cached artist albums method
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
// 1. Show cached data immediately (if available)
final cachedAlbums = provider.getCachedArtistAlbums(widget.artist.name);
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
final libraryAlbums = cachedAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = cachedAlbums.where((a) => !a.inLibrary).toList();
if (mounted) {
// Separate library albums from provider-only albums
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
if (mounted) {
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums();
_isLoading = false;
});
}
}
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums(); // Apply saved sort order
_isLoading = false;
});
// 2. Fetch fresh data in background (silent refresh)
try {
final allAlbums = await provider.getArtistAlbumsWithCache(
widget.artist.name,
forceRefresh: cachedAlbums != null,
);
if (mounted && allAlbums.isNotEmpty) {
// Check if data actually changed
final albumsChanged = _albums.length != allAlbums.where((a) => a.inLibrary).length;
if (albumsChanged || _albums.isEmpty) {
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums();
_isLoading = false;
});
}
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
@@ -510,10 +561,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 200,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon(
Icons.person_rounded,
size: 100,
color: colorScheme.onSurfaceVariant,
@@ -817,6 +870,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: heroSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -833,12 +887,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, __, ___) => Center(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
@@ -940,10 +996,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 56,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? Image.network(
imageUrl,
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
color: colorScheme.onSurfaceVariant,
),
@@ -979,6 +1037,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: 'artist_albums',
initialImageUrl: imageUrl,
),
),
);
+14 -3
View File
@@ -19,12 +19,14 @@ class AudiobookAuthorScreen extends StatefulWidget {
final String authorName;
final List<Audiobook> audiobooks;
final String? heroTagSuffix;
final String? initialAuthorImageUrl;
const AudiobookAuthorScreen({
super.key,
required this.authorName,
required this.audiobooks,
this.heroTagSuffix,
this.initialAuthorImageUrl,
});
@override
@@ -48,6 +50,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
void initState() {
super.initState();
_audiobooks = List.from(widget.audiobooks);
// Use initial image URL immediately for smooth hero animation
_authorImageUrl = widget.initialAuthorImageUrl;
_loadViewPreferences();
_sortAudiobooks();
_loadAuthorImage();
@@ -181,6 +185,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
fit: BoxFit.cover,
width: 200,
height: 200,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.accountOutline,
size: 100,
@@ -329,7 +335,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final heroSuffix = 'author${_heroTagSuffix}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -349,6 +355,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
@@ -443,6 +451,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
@@ -508,17 +518,18 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
+34 -15
View File
@@ -15,11 +15,14 @@ import '../l10n/app_localizations.dart';
class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook;
final String? heroTagSuffix;
/// Initial image URL from the source for seamless hero animation
final String? initialImageUrl;
const AudiobookDetailScreen({
super.key,
required this.audiobook,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
@@ -119,10 +122,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
final newState = !_isFavorite;
bool success;
if (newState) {
String actualProvider = widget.audiobook.provider;
@@ -141,7 +144,11 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
}
_logger.log('Adding audiobook to favorites: provider=$actualProvider, itemId=$actualItemId');
await maProvider.api!.addToFavorites('audiobook', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
@@ -162,22 +169,30 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
}
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
await maProvider.api!.removeFromFavorites('audiobook', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
}
setState(() {
_isFavorite = newState;
});
if (success) {
setState(() {
_isFavorite = newState;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
duration: const Duration(seconds: 1),
),
);
);
}
}
} catch (e) {
_logger.log('Error toggling audiobook favorite: $e');
@@ -414,7 +429,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
@@ -528,6 +545,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
+10 -3
View File
@@ -517,6 +517,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
@@ -633,7 +635,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final heroSuffix = 'series_${widget.series.id}';
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -653,6 +655,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
@@ -746,6 +750,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
@@ -811,17 +817,18 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
+6
View File
@@ -62,6 +62,12 @@ class _HomeScreenState extends State<HomeScreen> {
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
// If player reveal (device list) is visible, dismiss it first (with animation)
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
GlobalPlayerOverlay.dismissPlayerReveal();
return;
}
// If global player is expanded, collapse it first
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
+3 -1
View File
@@ -47,7 +47,9 @@ class LibraryAlbumsScreen extends StatelessWidget {
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (albums.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
+3 -1
View File
@@ -49,7 +49,9 @@ class LibraryArtistsScreen extends StatelessWidget {
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (artists.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
+3 -1
View File
@@ -75,7 +75,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (_playlists.isEmpty && _isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
+3 -1
View File
@@ -42,7 +42,9 @@ class LibraryTracksScreen extends StatelessWidget {
BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
if (provider.isLoading) {
// Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading
if (provider.tracks.isEmpty && provider.isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
+12
View File
@@ -443,6 +443,18 @@ class _LoginScreenState extends State<LoginScreen> {
}
if (provider.isConnected) {
// For first-time users, wait for player selection so the welcome
// overlay can appear immediately without home screen flash.
// This matches the logic in AppStartup._checkAndConnect().
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!hasCompletedOnboarding) {
// First-time user: wait for connection + player selection
for (int i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (provider.selectedPlayer != null) break;
}
}
// Navigate to home screen
if (mounted) {
// Ensure keyboard is closed before navigating
+3
View File
@@ -304,6 +304,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('recent-albums'),
title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedRecentAlbums(),
rowHeight: rowHeight,
);
case 'discover-artists':
@@ -312,6 +313,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('discover-artists'),
title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(),
getCachedArtists: () => provider.getCachedDiscoverArtists(),
rowHeight: rowHeight,
);
case 'discover-albums':
@@ -320,6 +322,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('discover-albums'),
title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
rowHeight: rowHeight,
);
case 'continue-listening':
+18 -7
View File
@@ -1005,6 +1005,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover,
width: 48,
height: 48,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Text(
authorName.isNotEmpty ? authorName[0].toUpperCase() : '?',
style: TextStyle(
@@ -1047,7 +1049,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
color: colorScheme.onSurface.withOpacity(0.6),
),
),
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix'),
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix', initialAuthorImageUrl: authorImageUrl),
);
}
@@ -1059,7 +1061,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Match music artist card layout
return GestureDetector(
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix'),
onTap: () => _navigateToAuthor(authorName, books, heroTagSuffix: 'library$heroSuffix', initialAuthorImageUrl: authorImageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -1088,6 +1090,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover,
width: size,
height: size,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Text(
authorName.isNotEmpty ? authorName[0].toUpperCase() : '?',
@@ -1142,7 +1146,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
void _navigateToAuthor(String authorName, List<Audiobook> books, {String? heroTagSuffix}) {
void _navigateToAuthor(String authorName, List<Audiobook> books, {String? heroTagSuffix, String? initialAuthorImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
@@ -1150,6 +1154,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
authorName: authorName,
audiobooks: books,
heroTagSuffix: heroTagSuffix,
initialAuthorImageUrl: initialAuthorImageUrl,
),
),
);
@@ -1222,17 +1227,18 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix'),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix', initialImageUrl: imageUrl),
);
}
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
Navigator.push(
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: book,
heroTagSuffix: heroTagSuffix,
initialImageUrl: initialImageUrl,
),
),
);
@@ -1325,7 +1331,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final heroSuffix = _showFavoritesOnly ? '_fav' : '';
return GestureDetector(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix'),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: 'library$heroSuffix', initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1346,6 +1352,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
@@ -2188,7 +2196,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Navigator.push(
context,
FadeSlidePageRoute(
child: AlbumDetailsScreen(album: album),
child: AlbumDetailsScreen(
album: album,
initialImageUrl: imageUrl,
),
),
);
},
+40 -13
View File
@@ -35,21 +35,48 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
}
Future<void> _loadTracks() async {
setState(() {
_isLoading = true;
});
final maProvider = context.read<MusicAssistantProvider>();
final tracks = await maProvider.getPlaylistTracksWithCache(
widget.provider,
widget.itemId,
);
final cacheKey = '${widget.provider}_${widget.itemId}';
if (mounted) {
setState(() {
_tracks = tracks;
_isLoading = false;
});
// 1. Show cached data immediately (if available)
final cachedTracks = maProvider.getCachedPlaylistTracks(cacheKey);
if (cachedTracks != null && cachedTracks.isNotEmpty) {
if (mounted) {
setState(() {
_tracks = cachedTracks;
_isLoading = false;
});
}
} else {
setState(() => _isLoading = true);
}
// 2. Fetch fresh data in background (silent refresh)
try {
final freshTracks = await maProvider.getPlaylistTracksWithCache(
widget.provider,
widget.itemId,
forceRefresh: cachedTracks != null,
);
// 3. Update if we got different data
if (mounted && freshTracks.isNotEmpty) {
final tracksChanged = _tracks.length != freshTracks.length ||
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
_tracks.first.itemId != freshTracks.first.itemId);
if (tracksChanged || _tracks.isEmpty) {
setState(() {
_tracks = freshTracks;
_isLoading = false;
});
}
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
File diff suppressed because it is too large Load Diff
+269 -66
View File
@@ -6,6 +6,7 @@ import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart';
import 'login_screen.dart';
@@ -37,6 +38,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Audiobook libraries
List<Map<String, String>> _discoveredLibraries = [];
Map<String, bool> _libraryEnabled = {};
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
// Hint settings
bool _showHints = true;
@override
void initState() {
@@ -81,6 +87,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
libraryEnabled[path] = enabled == null || enabled.contains(path);
}
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
// Load hint settings
final showHints = await SettingsService.getShowHints();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -95,6 +108,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
_homeRowOrder = rowOrder;
_discoveredLibraries = discovered;
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_showHints = showHints;
});
}
}
@@ -410,74 +426,63 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16),
// Language picker
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.language,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
// Accent color picker
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDisabled = themeProvider.useMaterialTheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
const SizedBox(height: 12),
Consumer<LocaleProvider>(
builder: (context, localeProvider, _) {
return SizedBox(
width: double.infinity,
child: SegmentedButton<String?>(
segments: [
ButtonSegment<String?>(
value: null,
label: Text(S.of(context)!.system),
icon: const Icon(Icons.auto_mode_rounded),
),
const ButtonSegment<String?>(
value: 'en',
label: Text('English'),
icon: Icon(Icons.language_rounded),
),
const ButtonSegment<String?>(
value: 'de',
label: Text('Deutsch'),
icon: Icon(Icons.language_rounded),
),
],
selected: {localeProvider.locale?.languageCode},
onSelectionChanged: (Set<String?> newSelection) {
final code = newSelection.first;
localeProvider.setLocale(code != null ? Locale(code) : null);
},
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.primaryContainer;
}
return colorScheme.surfaceVariant.withOpacity(0.5);
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.onPrimaryContainer;
}
return colorScheme.onSurfaceVariant;
}),
side: WidgetStateProperty.all(BorderSide.none),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.accentColor,
style: TextStyle(
color: isDisabled
? colorScheme.onSurface.withOpacity(0.4)
: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
);
},
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: accentColorOptions.map((color) {
final isSelected = themeProvider.customColor.value == color.value;
return GestureDetector(
onTap: isDisabled ? null : () {
themeProvider.setCustomColor(color);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: 40,
height: 40,
decoration: BoxDecoration(
color: isDisabled ? color.withOpacity(0.3) : color,
shape: BoxShape.circle,
border: isSelected && !isDisabled
? Border.all(color: colorScheme.onSurface, width: 3)
: null,
boxShadow: isSelected && !isDisabled
? [BoxShadow(color: color.withOpacity(0.5), blurRadius: 8, spreadRadius: 2)]
: null,
),
child: isSelected && !isDisabled
? Icon(Icons.check, color: Colors.white, size: 20)
: null,
),
);
}).toList(),
),
],
),
],
),
);
},
),
const SizedBox(height: 16),
@@ -496,7 +501,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Use system colors (Android 12+)',
S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
@@ -542,6 +547,204 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Language section
Text(
S.of(context)!.language,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Consumer<LocaleProvider>(
builder: (context, localeProvider, _) {
String getLanguageName(String? code) {
switch (code) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
default:
return S.of(context)!.system;
}
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
tileColor: colorScheme.surfaceVariant.withOpacity(0.5),
leading: Icon(Icons.language_rounded, color: colorScheme.onSurfaceVariant),
title: Text(S.of(context)!.language),
subtitle: Text(
getLanguageName(localeProvider.locale?.languageCode),
style: TextStyle(color: colorScheme.primary),
),
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
onTap: () {
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(S.of(context)!.language),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String?>(
title: Text(S.of(context)!.system),
subtitle: const Text('Auto'),
value: null,
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(null);
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('English'),
value: 'en',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('en'));
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('Deutsch'),
value: 'de',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('de'));
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('Español'),
value: 'es',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('es'));
Navigator.pop(dialogContext);
},
),
],
),
);
},
);
},
);
},
),
const SizedBox(height: 32),
// Player section
Text(
S.of(context)!.players,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.preferLocalPlayer,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.preferLocalPlayerDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _preferLocalPlayer,
onChanged: (value) {
setState(() => _preferLocalPlayer = value);
SettingsService.setPreferLocalPlayer(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.smartSortPlayers,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.smartSortPlayersDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _smartSortPlayers,
onChanged: (value) {
setState(() => _smartSortPlayers = value);
SettingsService.setSmartSortPlayers(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Hints & Tips section
Text(
S.of(context)!.hintsAndTips,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.showHintsDescription,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.showHints,
style: TextStyle(color: colorScheme.onSurface),
),
value: _showHints,
onChanged: (value) {
setState(() => _showHints = value);
SettingsService.setShowHints(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Home Screen section
Text(
S.of(context)!.homeScreen,
+97 -19
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import 'no_auth_strategy.dart';
@@ -86,6 +87,22 @@ class AuthManager {
return baseUrl;
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local');
}
/// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
@@ -187,15 +204,50 @@ class AuthManager {
_logger.log('Checking MA API at: $apiUrl');
// Try to get server info - this tells us if auth is required
// Note: The command is just 'info', not 'server/info'
final response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
@@ -255,21 +307,47 @@ class AuthManager {
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
try {
// Test root endpoint - Music Assistant doesn't have /api/info
final testUrl = serverUrl;
_logger.log('No-auth test URL: $testUrl');
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
final response = await http.get(
Uri.parse(testUrl),
// Don't follow redirects - we want to see if server requires auth
).timeout(const Duration(seconds: 10));
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
_logger.log('No-auth status: ${response.statusCode}');
if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
// 200 = no auth required
// 302/307 = redirect (probably to auth)
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (response.statusCode == 200) {
if (statusCode == 200) {
return true;
}
+88
View File
@@ -1,12 +1,15 @@
import 'dart:convert';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../constants/timings.dart';
/// Centralized cache service for all app data caching
/// This is a non-notifying service - it just stores and retrieves cached data
class CacheService {
final DebugLogger _logger = DebugLogger();
bool _homeRowsLoaded = false;
// Home screen row caching
List<Album>? _cachedRecentAlbums;
@@ -57,6 +60,8 @@ class CacheService {
_cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('recent_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if discover artists cache is valid
@@ -76,6 +81,8 @@ class CacheService {
_cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discover_artists', artists.map((a) => a.toJson()).toList());
}
/// Check if discover albums cache is valid
@@ -95,6 +102,8 @@ class CacheService {
_cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Invalidate home screen cache (call on pull-to-refresh)
@@ -250,6 +259,11 @@ class CacheService {
_playerTrackCache[playerId] = track;
}
/// Clear cached track for a player (e.g., when external source is active)
void clearCachedTrackForPlayer(String playerId) {
_playerTrackCache.remove(playerId);
}
// ============================================================================
// CLEAR ALL
// ============================================================================
@@ -276,4 +290,78 @@ class CacheService {
_cachedSelectedPlayer = null;
_playersLastFetched = null;
}
// ============================================================================
// DATABASE PERSISTENCE FOR HOME ROWS
// ============================================================================
/// Load home row data from database for instant display on startup
Future<void> loadHomeRowsFromDatabase() async {
if (_homeRowsLoaded) return;
_homeRowsLoaded = true;
try {
if (!DatabaseService.instance.isInitialized) return;
// Load recent albums
final recentData = await DatabaseService.instance.getHomeRowCache('recent_albums');
if (recentData != null) {
try {
final items = (jsonDecode(recentData.itemsJson) as List)
.map((json) => Album.fromJson(json as Map<String, dynamic>))
.toList();
_cachedRecentAlbums = items;
_recentAlbumsLastFetched = recentData.lastUpdated;
_logger.log('📦 Loaded ${items.length} recent albums from database');
} catch (e) {
_logger.log('⚠️ Failed to parse recent albums: $e');
}
}
// Load discover artists
final artistsData = await DatabaseService.instance.getHomeRowCache('discover_artists');
if (artistsData != null) {
try {
final items = (jsonDecode(artistsData.itemsJson) as List)
.map((json) => Artist.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoverArtists = items;
_discoverArtistsLastFetched = artistsData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discover artists from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discover artists: $e');
}
}
// Load discover albums
final albumsData = await DatabaseService.instance.getHomeRowCache('discover_albums');
if (albumsData != null) {
try {
final items = (jsonDecode(albumsData.itemsJson) as List)
.map((json) => Album.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoverAlbums = items;
_discoverAlbumsLastFetched = albumsData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discover albums from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discover albums: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading home rows from database: $e');
}
}
/// Persist home row data to database (fire-and-forget)
void _persistHomeRowToDatabase(String rowType, List<Map<String, dynamic>> items) {
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
await DatabaseService.instance.saveHomeRowCache(rowType, jsonEncode(items));
_logger.log('💾 Persisted $rowType to database');
} catch (e) {
_logger.log('⚠️ Failed to persist $rowType: $e');
}
}();
}
}
+96
View File
@@ -156,4 +156,100 @@ class DatabaseService {
/// Clear all cached data (useful for logout)
Future<void> clearAllCache() => db.clearAllCache();
// ============================================
// Player Cache Convenience Methods
// ============================================
/// Save playback state for instant resume
Future<void> savePlaybackState({
String? playerId,
String? playerName,
String? currentTrackJson,
double positionSeconds = 0.0,
bool isPlaying = false,
}) {
return db.savePlaybackState(
playerId: playerId,
playerName: playerName,
currentTrackJson: currentTrackJson,
positionSeconds: positionSeconds,
isPlaying: isPlaying,
);
}
/// Get saved playback state
Future<PlaybackStateData?> getPlaybackState() => db.getPlaybackState();
/// Cache a player with optional track info
Future<void> cachePlayer({
required String playerId,
required String playerJson,
String? currentTrackJson,
}) {
return db.cachePlayer(
playerId: playerId,
playerJson: playerJson,
currentTrackJson: currentTrackJson,
);
}
/// Cache multiple players at once
Future<void> cachePlayers(List<Map<String, dynamic>> players) {
return db.cachePlayers(players);
}
/// Get all cached players
Future<List<CachedPlayer>> getCachedPlayers() => db.getCachedPlayers();
/// Update cached track for a player
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) {
return db.updateCachedPlayerTrack(playerId, trackJson);
}
/// Clear all cached players
Future<void> clearCachedPlayers() => db.clearCachedPlayers();
/// Save queue for a player
Future<void> saveQueue(String playerId, List<String> itemJsonList) {
return db.saveQueue(playerId, itemJsonList);
}
/// Get cached queue for a player
Future<List<CachedQueueData>> getCachedQueue(String playerId) {
return db.getCachedQueue(playerId);
}
/// Clear queue for a player
Future<void> clearCachedQueue(String playerId) => db.clearCachedQueue(playerId);
// ============================================
// Home Row Cache Convenience Methods
// ============================================
/// Save home row data (recent albums, discover artists, etc.)
Future<void> saveHomeRowCache(String rowType, String itemsJson) {
return db.saveHomeRowCache(rowType, itemsJson);
}
/// Get cached home row data
Future<HomeRowCacheData?> getHomeRowCache(String rowType) {
return db.getHomeRowCache(rowType);
}
/// Clear all home row cache
Future<void> clearHomeRowCache() => db.clearHomeRowCache();
// ============================================
// Search History Convenience Methods
// ============================================
/// Save a search query to history
Future<void> saveSearchQuery(String query) => db.saveSearchQuery(query);
/// Get recent search queries (up to 10)
Future<List<String>> getRecentSearches() => db.getRecentSearches();
/// Clear all search history
Future<void> clearSearchHistory() => db.clearSearchHistory();
}
+120 -101
View File
@@ -1069,16 +1069,14 @@ class MusicAssistantAPI {
return [];
}
// Batch fetch track details using parallel requests (max 5 concurrent)
final seenAlbumIds = <String>{};
final albums = <Album>[];
// Step 1: Fetch tracks in batches and collect unique album URIs
final seenAlbumUris = <String>{};
final albumUris = <String>[];
// Process in batches to avoid overwhelming the server
const batchSize = LibraryConstants.recentAlbumsBatchSize;
for (var i = 0; i < trackUris.length && albums.length < limit; i += batchSize) {
for (var i = 0; i < trackUris.length && albumUris.length < limit; i += batchSize) {
final batch = trackUris.skip(i).take(batchSize).toList();
// Fetch batch in parallel
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
@@ -1087,27 +1085,52 @@ class MusicAssistantAPI {
final results = await Future.wait(futures, eagerError: false);
for (final trackResponse in results) {
if (albums.length >= limit) break;
if (albumUris.length >= limit) break;
try {
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
final albumUri = albumData?['uri'] as String?;
if (albumData != null) {
final albumId = albumData['item_id']?.toString() ?? albumData['uri']?.toString();
if (albumId != null && !seenAlbumIds.contains(albumId)) {
seenAlbumIds.add(albumId);
albums.add(Album.fromJson(albumData));
}
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
seenAlbumUris.add(albumUri);
albumUris.add(albumUri);
}
} catch (_) {
// Skip tracks that fail to parse
continue;
}
}
}
_logger.log('📚 Fetched ${albums.length} recent albums using ${(trackUris.length / batchSize).ceil()} batch requests');
if (albumUris.isEmpty) {
return [];
}
// Step 2: Fetch full album details (with images) for each album URI
final albums = <Album>[];
for (var i = 0; i < albumUris.length; i += batchSize) {
final batch = albumUris.skip(i).take(batchSize).toList();
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
final results = await Future.wait(futures, eagerError: false);
for (final albumResponse in results) {
try {
final albumData = albumResponse['result'] as Map<String, dynamic>?;
if (albumData != null) {
albums.add(Album.fromJson(albumData));
}
} catch (_) {
continue;
}
}
}
_logger.log('📚 Fetched ${albums.length} recent albums with full details');
return albums;
} catch (e) {
_logger.log('❌ Error getting recent albums: $e');
@@ -1376,7 +1399,7 @@ class MusicAssistantAPI {
final result = searchResponse['result'] as Map<String, dynamic>?;
if (result == null) {
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
// Parse results from the search response
@@ -1395,6 +1418,16 @@ class MusicAssistantAPI {
.toList() ??
[];
final playlists = (result['playlists'] as List<dynamic>?)
?.map((item) => Playlist.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
final audiobooks = (result['audiobooks'] as List<dynamic>?)
?.map((item) => Audiobook.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
// Deduplicate results by name
// MA returns both library items and provider items for the same content
// Prefer library items (provider='library') as they have all provider mappings
@@ -1402,15 +1435,17 @@ class MusicAssistantAPI {
'artists': _deduplicateResults(artists),
'albums': _deduplicateResults(albums),
'tracks': _deduplicateResults(tracks),
'playlists': _deduplicateResults(playlists),
'audiobooks': _deduplicateResults(audiobooks),
};
} catch (e) {
_logger.log('Error searching: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
},
).catchError((e) {
_logger.log('Error searching after retries: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
});
}
@@ -1579,6 +1614,25 @@ class MusicAssistantAPI {
}
}
/// Add a single track to the queue without starting playback
Future<void> addTrackToQueue(String playerId, Track track) async {
try {
final uri = _buildTrackUri(track);
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add', // Add to queue without starting
},
);
} catch (e) {
_logger.log('Error adding track to queue: $e');
rethrow;
}
}
/// Play multiple tracks via queue
/// If clearQueue is true, replaces the queue (default behavior)
/// If startIndex is provided, only tracks from that index onwards will be queued
@@ -1973,6 +2027,45 @@ class MusicAssistantAPI {
}
}
/// Sync a player to a leader/group (temporary group)
/// The player will join the leader's sync group and play the same audio
Future<void> syncPlayerToLeader(String playerId, String leaderPlayerId) async {
try {
_logger.log('🔗 API syncPlayerToLeader called');
_logger.log('🔗 player_id: $playerId');
_logger.log('🔗 target_player: $leaderPlayerId');
final response = await _sendCommand(
'players/cmd/group',
args: {
'player_id': playerId,
'target_player': leaderPlayerId,
},
);
_logger.log('✅ Player synced successfully, response: $response');
} catch (e) {
_logger.log('❌ Error syncing player: $e');
rethrow;
}
}
/// Remove a player from its sync group
Future<void> unsyncPlayer(String playerId) async {
try {
_logger.log('🔓 Unsyncing player: $playerId');
await _sendCommand(
'players/cmd/ungroup',
args: {
'player_id': playerId,
},
);
_logger.log('✅ Player unsynced successfully');
} catch (e) {
_logger.log('Error unsyncing player: $e');
rethrow;
}
}
/// Toggle shuffle mode for queue
Future<void> toggleShuffle(String queueId) async {
try {
@@ -2302,89 +2395,15 @@ class MusicAssistantAPI {
return _eventStreams['sendspin_player']!.stream;
}
/// Get Sendspin connection info for a player
/// Returns connection details including:
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
/// - player_id: The player's ID
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
try {
_logger.log('Sendspin: Getting connection info for player $playerId');
final response = await _sendCommand(
'sendspin/connection_info',
args: {
'player_id': playerId,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result != null) {
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
}
return result;
} catch (e) {
_logger.log('Sendspin: Error getting connection info: $e');
return null;
}
}
/// Send WebRTC offer to Sendspin server
/// Used when establishing WebRTC connection as fallback
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
final response = await _sendCommand(
'sendspin/webrtc_offer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
return response['result'] as Map<String, dynamic>?;
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC offer: $e');
return null;
}
}
/// Send WebRTC answer to Sendspin server
Future<void> sendspinAnswer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
await _sendCommand(
'sendspin/webrtc_answer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC answer: $e');
rethrow;
}
}
/// Send ICE candidate to Sendspin server
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
try {
await _sendCommand(
'sendspin/ice_candidate',
args: {
'player_id': playerId,
'candidate': candidate,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending ICE candidate: $e');
// Don't rethrow - ICE candidate failures are common and non-fatal
}
}
// NOTE: The following Sendspin API methods were removed because they don't exist in MA:
// - getSendspinConnectionInfo (sendspin/connection_info)
// - sendspinOffer (sendspin/webrtc_offer)
// - sendspinAnswer (sendspin/webrtc_answer)
// - sendspinIceCandidate (sendspin/ice_candidate)
//
// Sendspin connection is handled directly via WebSocket to:
// - Local: ws://{server-ip}:8927/sendspin
// - External: wss://{server}/sendspin (via MA's proxy with auth)
/// Update Sendspin player state
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
+138
View File
@@ -0,0 +1,138 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'debug_logger.dart';
/// Represents a queued action that will be executed when connection is restored
class QueuedAction {
final String type;
final Map<String, dynamic> params;
final DateTime queuedAt;
QueuedAction({
required this.type,
required this.params,
required this.queuedAt,
});
Map<String, dynamic> toJson() => {
'type': type,
'params': params,
'queuedAt': queuedAt.toIso8601String(),
};
factory QueuedAction.fromJson(Map<String, dynamic> json) => QueuedAction(
type: json['type'] as String,
params: json['params'] as Map<String, dynamic>,
queuedAt: DateTime.parse(json['queuedAt'] as String),
);
}
/// Service for queuing actions when offline and executing them when back online
class OfflineActionQueue {
static OfflineActionQueue? _instance;
static OfflineActionQueue get instance => _instance ??= OfflineActionQueue._();
OfflineActionQueue._();
final _logger = DebugLogger();
static const _prefsKey = 'offline_action_queue';
List<QueuedAction> _queue = [];
bool _isProcessing = false;
/// Get the current queue (for display)
List<QueuedAction> get queue => List.unmodifiable(_queue);
/// Check if there are pending actions
bool get hasPendingActions => _queue.isNotEmpty;
/// Number of pending actions
int get pendingCount => _queue.length;
/// Initialize and load persisted queue
Future<void> initialize() async {
try {
final prefs = await SharedPreferences.getInstance();
final queueJson = prefs.getString(_prefsKey);
if (queueJson != null) {
final List<dynamic> decoded = jsonDecode(queueJson);
_queue = decoded.map((j) => QueuedAction.fromJson(j as Map<String, dynamic>)).toList();
_logger.log('📋 Loaded ${_queue.length} offline actions from storage');
}
} catch (e) {
_logger.log('⚠️ Error loading offline action queue: $e');
}
}
/// Queue an action for later execution
Future<void> queueAction(String type, Map<String, dynamic> params) async {
final action = QueuedAction(
type: type,
params: params,
queuedAt: DateTime.now(),
);
_queue.add(action);
await _persistQueue();
_logger.log('📋 Queued offline action: $type');
}
/// Process all queued actions (call when connection is restored)
Future<void> processQueue(Future<bool> Function(QueuedAction action) executor) async {
if (_isProcessing || _queue.isEmpty) return;
_isProcessing = true;
_logger.log('🔄 Processing ${_queue.length} offline actions...');
final processed = <QueuedAction>[];
for (final action in _queue) {
try {
final success = await executor(action);
if (success) {
processed.add(action);
_logger.log('✅ Processed offline action: ${action.type}');
} else {
_logger.log('⚠️ Failed to process offline action: ${action.type}');
}
} catch (e) {
_logger.log('❌ Error processing offline action: $e');
}
}
// Remove processed actions
_queue.removeWhere((a) => processed.contains(a));
await _persistQueue();
_isProcessing = false;
_logger.log('📋 ${processed.length} actions processed, ${_queue.length} remaining');
}
/// Clear all queued actions
Future<void> clearQueue() async {
_queue.clear();
await _persistQueue();
_logger.log('🗑️ Offline action queue cleared');
}
/// Persist queue to storage
Future<void> _persistQueue() async {
try {
final prefs = await SharedPreferences.getInstance();
if (_queue.isEmpty) {
await prefs.remove(_prefsKey);
} else {
final queueJson = jsonEncode(_queue.map((a) => a.toJson()).toList());
await prefs.setString(_prefsKey, queueJson);
}
} catch (e) {
_logger.log('⚠️ Error persisting offline action queue: $e');
}
}
}
/// Action types for offline queuing
class OfflineActionTypes {
static const String toggleFavorite = 'toggle_favorite';
static const String addToPlaylist = 'add_to_playlist';
static const String removeFromPlaylist = 'remove_from_playlist';
}
+10
View File
@@ -25,6 +25,8 @@ class RecentlyPlayedService {
if (!_db.isInitialized) return;
try {
// Store images metadata for instant artwork display
final images = album.metadata?['images'];
await _db.addRecentlyPlayed(
mediaId: album.itemId,
mediaType: 'album',
@@ -33,6 +35,7 @@ class RecentlyPlayedService {
metadata: {
'provider': album.provider,
'uri': album.uri,
if (images != null) 'images': images,
},
);
_logger.log('📝 Recorded album play: ${album.name}');
@@ -151,6 +154,12 @@ class RecentlyPlayedService {
} catch (_) {}
}
// Build metadata with images if available (for instant artwork display)
Map<String, dynamic>? albumMetadata;
if (metadata?['images'] != null) {
albumMetadata = {'images': metadata!['images']};
}
albums.add(Album(
itemId: item.mediaId,
provider: metadata?['provider'] ?? 'library',
@@ -160,6 +169,7 @@ class RecentlyPlayedService {
artists: item.artistName != null
? [Artist(itemId: '', provider: '', name: item.artistName!)]
: null,
metadata: albumMetadata,
));
if (albums.length >= limit) break;
+7 -7
View File
@@ -120,12 +120,11 @@ class SendspinService {
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
}
// Strategy 2: If external failed or server is HTTP, try local connection
// Note: We would need local_ws_url from getSendspinConnectionInfo API
// For now, skip this step and fall back to WebRTC (handled by provider)
// Note: Local network connections (ws://ip:8927/sendspin) are handled
// by the provider calling connectWithUrl() directly, not by this method.
if (!connected) {
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
_logger.log('Sendspin: External proxy connection failed');
_updateState(SendspinConnectionState.error);
return false;
}
@@ -519,8 +518,9 @@ class SendspinService {
if (muted != null) _isMuted = muted;
// Determine player state for Sendspin protocol
// 'synchronized' = ready/playing, 'idle' = not playing
final playerState = _isPlaying || _isPaused ? 'synchronized' : 'idle';
// Valid values: 'synchronized', 'error', 'external_source'
// When connected, we're always synchronized (ready to receive audio)
const playerState = 'synchronized';
_sendMessage({
'type': 'client/state',
@@ -581,7 +581,7 @@ class SendspinService {
_sendMessage({
'type': 'client/time',
'payload': {
'timestamp': timestampMicroseconds,
'client_transmitted': timestampMicroseconds,
},
}, allowDuringHandshake: allowDuringHandshake);
}
+67
View File
@@ -22,6 +22,8 @@ class SettingsService {
static const String _keyLocalPlayerName = 'local_player_name';
static const String _keyOwnerName = 'owner_name';
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
static const String _keyPreferLocalPlayer = 'prefer_local_player';
static const String _keySmartSortPlayers = 'smart_sort_players';
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
@@ -72,6 +74,11 @@ class SettingsService {
static const String _keyEnabledAbsLibraries = 'enabled_abs_libraries'; // JSON list of library paths
static const String _keyDiscoveredAbsLibraries = 'discovered_abs_libraries'; // JSON list of {path, name}
// Hint System Settings
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyServerUrl);
@@ -364,6 +371,28 @@ class SettingsService {
}
}
// Prefer Local Player - always select local player first when available
static Future<bool> getPreferLocalPlayer() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyPreferLocalPlayer) ?? false;
}
static Future<void> setPreferLocalPlayer(bool prefer) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyPreferLocalPlayer, prefer);
}
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
static Future<bool> getSmartSortPlayers() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keySmartSortPlayers) ?? false;
}
static Future<void> setSmartSortPlayers(bool smartSort) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keySmartSortPlayers, smartSort);
}
// Helper to create player name with possessive apostrophe
static String _makePlayerName(String ownerName) {
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
@@ -738,4 +767,42 @@ class SettingsService {
await setEnabledAbsLibraries(enabled);
}
// Hint System Settings
/// Get whether hints are enabled (default: true)
static Future<bool> getShowHints() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyShowHints) ?? true;
}
/// Set whether hints are enabled
static Future<void> setShowHints(bool show) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyShowHints, show);
}
/// Check if user has ever used the player reveal gesture
static Future<bool> getHasUsedPlayerReveal() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyHasUsedPlayerReveal) ?? false;
}
/// Mark that user has used the player reveal gesture
static Future<void> setHasUsedPlayerReveal(bool hasUsed) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyHasUsedPlayerReveal, hasUsed);
}
/// Check if user has completed onboarding (seen welcome screen)
static Future<bool> getHasCompletedOnboarding() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_keyHasCompletedOnboarding) ?? false;
}
/// Mark that user has completed onboarding
static Future<void> setHasCompletedOnboarding(bool completed) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyHasCompletedOnboarding, completed);
}
}
+32 -1
View File
@@ -4,7 +4,17 @@ import 'package:flutter/material.dart';
const Color brandPrimaryColor = Color(0xFF1a1a1a);
const Color brandAccentColor = Color(0xFF604CEC);
// Brand color schemes
// Predefined accent colors for custom theme selection
const List<Color> accentColorOptions = [
Color(0xFF604CEC), // Purple (default)
Color(0xFF2196F3), // Blue
Color(0xFF009688), // Teal
Color(0xFFFF5722), // Deep Orange
Color(0xFFE91E63), // Pink
Color(0xFF4CAF50), // Green
];
// Brand color schemes (default)
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
seedColor: brandAccentColor,
brightness: Brightness.light,
@@ -21,6 +31,27 @@ final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
background: const Color(0xFF1a1a1a),
);
// Generate color schemes from a custom seed color
ColorScheme generateLightColorScheme(Color seedColor) {
return ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
background: const Color(0xFFFAFAFA),
onBackground: const Color(0xFF1a1a1a),
surface: const Color(0xFFFFFFFF),
onSurface: const Color(0xFF1a1a1a),
);
}
ColorScheme generateDarkColorScheme(Color seedColor) {
return ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
surface: const Color(0xFF2a2a2a),
background: const Color(0xFF1a1a1a),
);
}
class AppTheme {
// Generate light theme
static ThemeData lightTheme({ColorScheme? colorScheme}) {
+3 -1
View File
@@ -97,6 +97,7 @@ class _AlbumCardState extends State<AlbumCard> {
child: AlbumDetailsScreen(
album: widget.album,
heroTagSuffix: widget.heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -119,7 +120,8 @@ class _AlbumCardState extends State<AlbumCard> {
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
+111 -69
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -10,6 +11,8 @@ class AlbumRow extends StatefulWidget {
final Future<List<Album>> Function() loadAlbums;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Album>? Function()? getCachedAlbums;
const AlbumRow({
super.key,
@@ -17,6 +20,7 @@ class AlbumRow extends StatefulWidget {
required this.loadAlbums,
this.heroTagSuffix,
this.rowHeight,
this.getCachedAlbums,
});
@override
@@ -24,8 +28,8 @@ class AlbumRow extends StatefulWidget {
}
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
late Future<List<Album>> _albumsFuture;
List<Album>? _cachedAlbums; // Keep last loaded data to prevent flash
List<Album> _albums = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
@@ -34,21 +38,117 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
@override
void initState() {
super.initState();
_loadAlbumsOnce();
_loadAlbums();
}
void _loadAlbumsOnce() {
if (!_hasLoaded) {
_albumsFuture = widget.loadAlbums().then((albums) {
_cachedAlbums = albums;
return albums;
});
_hasLoaded = true;
Future<void> _loadAlbums() async {
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedAlbums = widget.getCachedAlbums?.call();
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
if (mounted) {
setState(() {
_albums = cachedAlbums;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshAlbums = await widget.loadAlbums();
if (mounted && freshAlbums.isNotEmpty) {
setState(() {
_albums = freshAlbums;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheAlbumImages(freshAlbums);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache album images so hero animations are smooth on first tap
void _precacheAlbumImages(List<Album> albums) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final albumsToCache = albums.take(10);
for (final album in albumsToCache) {
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_albums.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = _albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('AlbumRow:${widget.title}');
@@ -78,65 +178,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
),
),
Expanded(
child: FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final albums = snapshot.data ?? _cachedAlbums;
// Only show loading if we have no data at all
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && albums == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (albums == null || albums.isEmpty) {
return Center(
child: Text(
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: albums.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final album = albums[index];
return Container(
key: ValueKey(album.uri ?? album.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: AlbumCard(
album: album,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme),
),
],
),
+2
View File
@@ -100,6 +100,8 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
width: widget.radius * 2,
height: widget.radius * 2,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
color: colorScheme.surfaceVariant,
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
+44 -43
View File
@@ -100,49 +100,50 @@ class _ArtistCardState extends State<ArtistCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Artist image - circular, constrained to be a proper circle
Expanded(
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
// Use the smaller dimension to ensure a circle
final size = constraints.maxWidth < constraints.maxHeight
? constraints.maxWidth
: constraints.maxHeight;
return Hero(
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval(
child: Container(
width: size,
height: size,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
if (!_maImageFailed && url == maImageUrl) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onImageError();
});
}
return Icon(
Icons.person_rounded,
size: size * 0.55,
color: colorScheme.onSurfaceVariant,
);
},
)
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
),
),
);
},
// Artist image - circular
// PERF: Use AspectRatio instead of LayoutBuilder to provide fixed geometry
// before Hero animation starts. This prevents grey icon flash caused by
// dynamic sizing inside the Hero widget.
AspectRatio(
aspectRatio: 1.0,
child: Hero(
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: ClipOval(
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
if (!_maImageFailed && url == maImageUrl) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onImageError();
});
}
return Center(
child: Icon(
Icons.person_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
);
},
)
: Center(
child: Icon(
Icons.person_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
+111 -69
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -10,6 +11,8 @@ class ArtistRow extends StatefulWidget {
final Future<List<Artist>> Function() loadArtists;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Artist>? Function()? getCachedArtists;
const ArtistRow({
super.key,
@@ -17,6 +20,7 @@ class ArtistRow extends StatefulWidget {
required this.loadArtists,
this.heroTagSuffix,
this.rowHeight,
this.getCachedArtists,
});
@override
@@ -24,8 +28,8 @@ class ArtistRow extends StatefulWidget {
}
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
late Future<List<Artist>> _artistsFuture;
List<Artist>? _cachedArtists; // Keep last loaded data to prevent flash
List<Artist> _artists = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
@@ -34,21 +38,117 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
@override
void initState() {
super.initState();
_loadArtistsOnce();
_loadArtists();
}
void _loadArtistsOnce() {
if (!_hasLoaded) {
_artistsFuture = widget.loadArtists().then((artists) {
_cachedArtists = artists;
return artists;
});
_hasLoaded = true;
Future<void> _loadArtists() async {
if (_hasLoaded) return;
_hasLoaded = true;
// 1. Try to get cached data synchronously for instant display
final cachedArtists = widget.getCachedArtists?.call();
if (cachedArtists != null && cachedArtists.isNotEmpty) {
if (mounted) {
setState(() {
_artists = cachedArtists;
_isLoading = false;
});
}
}
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshArtists = await widget.loadArtists();
if (mounted && freshArtists.isNotEmpty) {
setState(() {
_artists = freshArtists;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheArtistImages(freshArtists);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache artist images so hero animations are smooth on first tap
void _precacheArtistImages(List<Artist> artists) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final artistsToCache = artists.take(10);
for (final artist in artistsToCache) {
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_artists.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_artists.isEmpty) {
return Center(
child: Text(
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _artists.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final artist = _artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('ArtistRow:${widget.title}');
@@ -78,65 +178,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
),
),
Expanded(
child: FutureBuilder<List<Artist>>(
future: _artistsFuture,
builder: (context, snapshot) {
// Use cached data immediately if available (prevents flash on rebuild)
final artists = snapshot.data ?? _cachedArtists;
// Only show loading if we have no data at all
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError && artists == null) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (artists == null || artists.isEmpty) {
return Center(
child: Text(
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: artists.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final artist = artists[index];
return Container(
key: ValueKey(artist.uri ?? artist.itemId),
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: ArtistCard(
artist: artist,
heroTagSuffix: widget.heroTagSuffix,
),
);
},
),
);
},
),
child: _buildContent(contentHeight, colorScheme),
),
],
),
+6
View File
@@ -173,6 +173,7 @@ class _AudiobookCard extends StatelessWidget {
child: AudiobookDetailScreen(
audiobook: audiobook,
heroTagSuffix: heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -195,6 +196,11 @@ class _AudiobookCard extends StatelessWidget {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// PERF: Duration.zero for hero-wrapped images
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
memCacheWidth: 256,
memCacheHeight: 256,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
+351 -216
View File
@@ -15,6 +15,7 @@ import 'animated_icon_button.dart';
import 'global_player_overlay.dart';
import 'volume_control.dart';
import 'player/player_widgets.dart';
import 'player/mini_player_content.dart';
/// A unified player widget that seamlessly expands from mini to full-screen.
///
@@ -25,9 +26,27 @@ class ExpandablePlayer extends StatefulWidget {
/// Slide offset for hiding the mini player (0.0 = visible, 1.0 = hidden below screen)
final double slideOffset;
/// Bounce offset for the player reveal animation (moves mini player down slightly)
final double bounceOffset;
/// Callback when swipe-down gesture triggers player reveal
final VoidCallback? onRevealPlayers;
/// Whether the device reveal overlay is currently visible
/// When true, shows player name instead of track name in collapsed state
final bool isDeviceRevealVisible;
/// Whether the hint text should be shown
/// When true, shows "Pull to select players" instead of track info
final bool isHintVisible;
const ExpandablePlayer({
super.key,
this.slideOffset = 0.0,
this.bounceOffset = 0.0,
this.onRevealPlayers,
this.isDeviceRevealVisible = false,
this.isHintVisible = false,
});
@override
@@ -65,6 +84,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
static const double _bottomNavHeight = 56.0;
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
// Pastel yellow for grouped players (matches PlayerCard.groupBorderColor)
static const Color _groupBorderColor = Color(0xFFFFF59D);
// Track horizontal drag start position
double? _horizontalDragStartX;
@@ -91,6 +113,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
String? _lastMeasuredTrackName;
double? _lastMeasuredTitleWidth;
// Gesture-driven expansion state
bool _isVerticalDragging = false;
double _dragStartY = 0;
double _dragStartValue = 0;
// Maximum drag distance for full expand/collapse
static const double _maxDragDistance = 300.0;
// Thresholds for committing expand/collapse
static const double _commitPositionThreshold = 0.3; // 30% progress
static const double _commitVelocityThreshold = 800.0; // px/s
@override
void initState() {
super.initState();
@@ -98,6 +132,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
duration: const Duration(milliseconds: 300),
vsync: this,
);
// Simple curved animation - performant and smooth
_expandAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
@@ -128,7 +164,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
);
_controller.addStatusListener((status) {
if (status == AnimationStatus.forward) {
// PERF: Load queue after animation completes, not during
// This prevents network I/O from competing with animation frames
if (status == AnimationStatus.completed) {
_loadQueue();
} else if (status == AnimationStatus.dismissed) {
// Close queue panel when player collapses
@@ -182,23 +220,94 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
AnimationDebugger.recordFrame(_controller.value);
}
// Animation durations - asymmetric for snappier collapse
static const Duration _expandDuration = Duration(milliseconds: 280);
static const Duration _collapseDuration = Duration(milliseconds: 200);
void expand() {
if (_isVerticalDragging) return;
AnimationDebugger.startSession('playerExpand');
_controller.duration = _expandDuration;
_controller.forward().then((_) {
AnimationDebugger.endSession();
});
}
void collapse() {
if (_isVerticalDragging) return;
AnimationDebugger.startSession('playerCollapse');
// Instantly hide queue panel when collapsing to avoid visual glitches
// during Android's predictive back gesture
_queuePanelController.value = 0;
_controller.duration = _collapseDuration;
_controller.reverse().then((_) {
AnimationDebugger.endSession();
});
}
/// Handle vertical drag start - begin gesture-driven expansion
void _handleVerticalDragStart(DragStartDetails details) {
_isVerticalDragging = true;
_dragStartY = details.globalPosition.dy;
_dragStartValue = _controller.value;
_controller.stop(); // Stop any running animation
}
/// Handle vertical drag update - finger tracking
void _handleVerticalDragUpdate(DragUpdateDetails details) {
if (!_isVerticalDragging) return;
final dragDelta = _dragStartY - details.globalPosition.dy;
// Swipe up = positive delta = expand (increase value)
// Swipe down = negative delta = collapse (decrease value)
final normalizedDelta = dragDelta / _maxDragDistance;
final newValue = (_dragStartValue + normalizedDelta).clamp(0.0, 1.0);
_controller.value = newValue;
}
/// Handle vertical drag end - decide to commit or snap back
void _handleVerticalDragEnd(DragEndDetails details) {
if (!_isVerticalDragging) return;
_isVerticalDragging = false;
final velocity = details.primaryVelocity ?? 0;
final currentValue = _controller.value;
// Determine direction based on velocity first, then position
bool shouldExpand;
if (velocity.abs() > _commitVelocityThreshold) {
// High velocity: use velocity direction (negative = swipe up = expand)
shouldExpand = velocity < 0;
} else if (currentValue > _commitPositionThreshold && currentValue < (1 - _commitPositionThreshold)) {
// In the middle zone: use velocity direction if any, otherwise snap to nearest
shouldExpand = velocity < 0 || (velocity == 0 && currentValue > 0.5);
} else {
// Near edges: commit to nearest endpoint
shouldExpand = currentValue > 0.5;
}
// Animate to target with appropriate duration
if (shouldExpand) {
if (currentValue < 1.0) {
AnimationDebugger.startSession('playerExpand');
_controller.duration = _expandDuration;
_controller.forward().then((_) {
AnimationDebugger.endSession();
});
}
} else {
if (currentValue > 0.0) {
AnimationDebugger.startSession('playerCollapse');
_queuePanelController.value = 0;
_controller.duration = _collapseDuration;
_controller.reverse().then((_) {
AnimationDebugger.endSession();
});
}
}
}
bool get isExpanded => _controller.value > 0.5;
double get expansionProgress => _controller.value;
@@ -227,24 +336,49 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
Future<void> _loadQueue() async {
if (_isLoadingQueue) return;
setState(() => _isLoadingQueue = true);
final maProvider = context.read<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
if (player != null && maProvider.api != null) {
final queue = await maProvider.api!.getQueue(player.playerId);
if (mounted) {
setState(() {
_queue = queue;
_isLoadingQueue = false;
});
}
if (player == null) {
if (mounted) setState(() => _isLoadingQueue = false);
return;
}
// 1. Show cached queue immediately (if available)
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
setState(() {
_queue = cachedQueue;
_isLoadingQueue = false;
});
} else {
if (mounted) {
setState(() => _isLoadingQueue = false);
setState(() => _isLoadingQueue = true);
}
// 2. Fetch fresh queue in background
if (maProvider.api != null) {
try {
final freshQueue = await maProvider.getQueue(player.playerId);
if (mounted && freshQueue != null) {
// Only update if queue changed
final queueChanged = _queue == null ||
_queue!.items.length != freshQueue.items.length ||
_queue!.currentIndex != freshQueue.currentIndex;
if (queueChanged) {
setState(() {
_queue = freshQueue;
_isLoadingQueue = false;
});
}
}
} catch (e) {
// Silent failure - keep showing cached queue
}
}
if (mounted && _isLoadingQueue) {
setState(() => _isLoadingQueue = false);
}
}
Future<void> _extractColors(String imageUrl) async {
@@ -324,14 +458,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
}
/// Toggle favorite status for current track
/// Toggle favorite status for current track (with offline queuing support)
Future<void> _toggleCurrentTrackFavorite(dynamic currentTrack) async {
if (currentTrack == null) return;
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api == null) return;
try {
bool success;
if (_isCurrentTrackFavorite) {
// Remove from favorites - need library_item_id (numeric)
int? libraryItemId;
@@ -353,7 +488,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
if (libraryItemId != null) {
await maProvider.api!.removeFromFavorites('track', libraryItemId);
success = await maProvider.removeFromFavorites(
mediaType: 'track',
libraryItemId: libraryItemId,
);
} else {
throw Exception('Could not determine library ID for this track');
}
@@ -386,16 +524,37 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
}
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
success = await maProvider.addToFavorites(
mediaType: 'track',
itemId: actualItemId,
provider: actualProvider,
);
}
// Toggle local state
setState(() {
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
});
if (success) {
// Toggle local state
setState(() {
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
});
// Invalidate home cache so favorites are updated
maProvider.invalidateHomeCache();
// Invalidate home cache so favorites are updated
maProvider.invalidateHomeCache();
// Show feedback (different message if offline)
if (mounted) {
final isOffline = !maProvider.isConnected;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isCurrentTrackFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
// Show error feedback
if (mounted) {
@@ -850,9 +1009,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final bottomOffset = bottomNavSpace + _collapsedMargin;
final width = screenSize.width - (_collapsedMargin * 2);
// Apply slide offset for hiding
// Apply slide offset for hiding and bounce offset for reveal animation
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
final adjustedBottomOffset = bottomOffset - slideDownAmount;
final bounceDownAmount = widget.bounceOffset;
final adjustedBottomOffset = bottomOffset - slideDownAmount - bounceDownAmount;
final availablePlayers = _getAvailablePlayersSorted(maProvider);
final hasMultiplePlayers = availablePlayers.length > 1;
@@ -861,7 +1021,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
left: _collapsedMargin,
right: _collapsedMargin,
bottom: adjustedBottomOffset,
child: DeviceSelectorBar(
child: GestureDetector(
onVerticalDragUpdate: (details) {
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
widget.onRevealPlayers!();
}
},
child: DeviceSelectorBar(
selectedPlayer: selectedPlayer,
peekPlayer: _peekPlayer,
hasMultiplePlayers: hasMultiplePlayers,
@@ -897,6 +1063,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragEnd(details, maProvider);
},
),
),
);
}
@@ -954,11 +1121,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedHeight = screenSize.height - bottomNavSpace;
// Apply slide offset to hide mini player (slides down off-screen)
// Only apply when collapsed (t == 0), don't affect expanded state
// Apply bounce offset for reveal animation (small downward movement)
// Smoothly fade out offsets as animation progresses (continuous, no discontinuity)
final slideDownAmount = widget.slideOffset * (_collapsedHeight + collapsedBottomOffset + 20);
final slideAdjustedBottomOffset = t < 0.1
? collapsedBottomOffset - slideDownAmount
: _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
final bounceDownAmount = widget.bounceOffset;
// Fade out slide/bounce effects smoothly over first 15% of animation
final offsetFade = (1.0 - (t / 0.15)).clamp(0.0, 1.0);
final baseBottomOffset = _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
final slideAdjustedBottomOffset = baseBottomOffset - (slideDownAmount + bounceDownAmount) * offsetFade;
final collapsedWidth = screenSize.width - (_collapsedMargin * 2);
final width = _lerpDouble(collapsedWidth, screenSize.width, t);
@@ -997,23 +1167,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedArtTop = topPadding + headerHeight + 16;
final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t);
// Typography - clear hierarchy
// Title: bold, prominent (24px)
// Artist: medium weight, secondary (18px expanded, was 16px)
// Album: light, tertiary (15px expanded, was 13px)
final titleFontSize = _lerpDouble(16.0, 24.0, t);
final artistFontSize = _lerpDouble(14.0, 18.0, t); // 14px collapsed, 18px expanded
// Typography - uses shared MiniPlayerLayout constants for collapsed state
final titleFontSize = _lerpDouble(MiniPlayerLayout.primaryFontSize, 24.0, t);
final artistFontSize = _lerpDouble(MiniPlayerLayout.secondaryFontSize, 18.0, t);
final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
final expandedTitleLeft = contentPadding;
final titleLeft = _lerpDouble(collapsedTitleLeft, expandedTitleLeft, t);
final titleLeft = _lerpDouble(MiniPlayerLayout.textLeft, expandedTitleLeft, t);
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
final collapsedTitleTop = MiniPlayerLayout.primaryTop;
// Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (right margin) = 114px from widget right
// For 8px gap: text ends at widgetWidth - 114 - 8 = widgetWidth - 122
// Note: collapsedWidth = screenSize.width - 24 (12px margin each side)
final collapsedTitleWidth = collapsedWidth - collapsedTitleLeft - 122;
final collapsedTitleWidth = collapsedWidth - MiniPlayerLayout.textLeft - 122;
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
@@ -1070,7 +1235,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
// Artist positioned dynamically based on actual title height
final collapsedArtistTop = collapsedTitleTop + 22; // Increased gap from 18 to 22
final collapsedArtistTop = MiniPlayerLayout.secondaryTop;
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
@@ -1102,7 +1267,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final selectedPlayerId = selectedPlayer.playerId;
// Calculate slide offset for mini player content (only when collapsed)
final miniPlayerSlideOffset = t < 0.1 ? _slideOffset * collapsedWidth : 0.0;
// Smoothly fade out horizontal slide offset (for device switching) as animation progresses
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
return Positioned(
left: horizontalMargin,
@@ -1113,13 +1279,47 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
behavior: HitTestBehavior.translucent,
// Only handle tap when collapsed - when expanded, let children handle their own taps
onTap: isExpanded ? null : expand,
onVerticalDragUpdate: (details) {
if (details.primaryDelta! < -10 && !isExpanded) {
expand();
} else if (details.primaryDelta! > 10 && isExpanded && !isQueuePanelOpen) {
collapse();
onVerticalDragStart: (details) {
// For expanded player or queue panel: start tracking immediately
// For collapsed player: defer decision until we know swipe direction
if (isExpanded || isQueuePanelOpen) {
_handleVerticalDragStart(details);
}
},
onVerticalDragUpdate: (details) {
final delta = details.primaryDelta ?? 0;
// Handle queue panel close
if (isQueuePanelOpen && delta > 0) {
_toggleQueuePanel();
return;
}
// Collapsed + not yet tracking: decide based on direction
if (!isExpanded && !_isVerticalDragging) {
if (delta > 5 && widget.onRevealPlayers != null) {
// Swipe down → show player reveal
widget.onRevealPlayers!();
return;
} else if (delta < -5) {
// Swipe up → start expand tracking
_handleVerticalDragStart(DragStartDetails(
globalPosition: details.globalPosition,
localPosition: details.localPosition,
));
} else {
// Not enough movement yet, wait for more
return;
}
}
// Gesture-driven expand/collapse
_handleVerticalDragUpdate(details);
},
onVerticalDragEnd: (details) {
// Finish gesture-driven expansion
_handleVerticalDragEnd(details);
},
onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx;
},
@@ -1160,13 +1360,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragEnd(details, maProvider);
}
},
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
elevation: _lerpDouble(4, 0, t),
shadowColor: Colors.black.withOpacity(0.3),
clipBehavior: Clip.antiAlias,
child: SizedBox(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
border: selectedPlayer.isGrouped && t < 0.5
? Border.all(color: _groupBorderColor, width: 1.5)
: null,
),
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
elevation: _lerpDouble(4, 0, t),
shadowColor: Colors.black.withOpacity(0.3),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: width,
height: height,
child: Stack(
@@ -1176,41 +1383,41 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Shows played portion with brighter color, unplayed with darker
// Starts after album art so progress is visible
// Slides with content during swipe, hidden during transition if peek available
// GPU PERF: Use conditional rendering + color alpha instead of Opacity
// to avoid double saveLayer (Opacity + ClipRRect)
if (t < 0.5 && currentTrack?.duration != null && !(_inTransition && t < 0.1 && _peekPlayer != null))
Positioned(
left: _collapsedArtSize + miniPlayerSlideOffset,
top: 0,
width: width - _collapsedArtSize,
bottom: 0,
child: ValueListenableBuilder<int>(
valueListenable: _progressNotifier,
builder: (context, elapsedSeconds, _) {
final totalSeconds = currentTrack!.duration!.inSeconds;
if (totalSeconds <= 0) return const SizedBox.shrink();
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
// Fade out as we expand
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
final progressAreaWidth = width - _collapsedArtSize;
return Opacity(
opacity: progressOpacity,
child: ClipRRect(
left: _collapsedArtSize + miniPlayerSlideOffset,
top: 0,
width: width - _collapsedArtSize,
bottom: 0,
child: ValueListenableBuilder<int>(
valueListenable: _progressNotifier,
builder: (context, elapsedSeconds, _) {
final totalSeconds = currentTrack!.duration!.inSeconds;
if (totalSeconds <= 0) return const SizedBox.shrink();
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
// Fade out as we expand - use color alpha instead of Opacity widget
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
final progressAreaWidth = width - _collapsedArtSize;
return ClipRRect(
borderRadius: BorderRadius.only(
topRight: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius),
),
clipBehavior: Clip.hardEdge,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: progressAreaWidth * progress,
height: height,
color: collapsedBg,
color: collapsedBg.withOpacity(progressOpacity),
),
),
),
);
},
);
},
),
),
),
// Peek player content (shows when dragging OR during transition)
// Show when: actively dragging (slideOffset != 0) OR in transition state
@@ -1218,20 +1425,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
_buildPeekContent(
context: context,
maProvider: maProvider,
peekPlayer: _peekPlayer,
peekImageUrl: _peekImageUrl,
slideOffset: _slideOffset,
containerWidth: collapsedWidth,
artSize: _collapsedArtSize,
titleLeft: collapsedTitleLeft,
titleTop: collapsedTitleTop,
titleWidth: collapsedTitleWidth,
artistTop: collapsedArtistTop,
titleFontSize: 16.0,
artistFontSize: 14.0,
backgroundColor: collapsedBg,
textColor: textColor,
colorScheme: colorScheme,
),
// Album art - with slide animation when collapsed
@@ -1289,23 +1488,64 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
// Track title - with slide animation when collapsed
// When hint is visible, show lightbulb icon + "Pull to select players"
// When device reveal is visible, show player name instead of track name
// Hidden during transition to prevent flash
// FALLBACK: Show if transition active but no peek content available
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
// Hint text - vertically centered in mini player
if (widget.isHintVisible && t < 0.5 && !(_inTransition && t < 0.1 && _peekPlayer != null))
Positioned(
left: titleLeft + miniPlayerSlideOffset,
// Center vertically: (64 - ~20) / 2 = 22
top: (MiniPlayerLayout.height - 20) / 2,
child: SizedBox(
width: titleWidth,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lightbulb_outline,
size: 16,
color: textColor,
),
const SizedBox(width: 6),
Flexible(
child: Text(
S.of(context)!.pullToSelectPlayers,
style: TextStyle(
color: textColor,
fontSize: titleFontSize,
fontWeight: MiniPlayerLayout.primaryFontWeight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
// Track title - with slide animation when collapsed
// Hidden when hint is visible
if (!(widget.isHintVisible && t < 0.5) && !(_inTransition && t < 0.1 && _peekPlayer != null))
Positioned(
left: titleLeft + miniPlayerSlideOffset,
top: titleTop,
child: SizedBox(
width: titleWidth,
child: Text(
currentTrack.name,
// Show player name when device reveal visible and collapsed
(widget.isDeviceRevealVisible && t < 0.5)
? selectedPlayer.name
: currentTrack.name,
style: TextStyle(
color: textColor,
fontSize: titleFontSize,
fontWeight: t > 0.5 ? FontWeight.w600 : FontWeight.w500,
fontWeight: t > 0.5 ? FontWeight.w600 : MiniPlayerLayout.primaryFontWeight,
letterSpacing: t > 0.5 ? -0.5 : 0,
height: 1.2,
height: t > 0.5 ? 1.2 : null, // Only use line height when expanded
),
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
maxLines: t > 0.5 ? 2 : 1,
@@ -1316,22 +1556,25 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
// Artist/Author name - with slide animation when collapsed
// When hint is visible, show blank line
// When device reveal is visible, show "Now Playing" hint
// For audiobooks: show author from audiobook context
// Hidden during transition to prevent flash
// FALLBACK: Show if transition active but no peek content available
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
if (!(_inTransition && t < 0.1 && _peekPlayer != null) && !(widget.isHintVisible && t < 0.5))
Positioned(
left: titleLeft + miniPlayerSlideOffset,
top: artistTop,
child: SizedBox(
width: titleWidth,
child: Text(
// Always show artist/author (was showing "Now Playing" when device reveal visible)
maProvider.isPlayingAudiobook
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
: currentTrack.artistsString,
style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
color: textColor.withOpacity(t > 0.5 ? 0.7 : MiniPlayerLayout.secondaryTextOpacity),
fontSize: artistFontSize,
fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal,
),
@@ -1707,163 +1950,55 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
),
),
),
),
);
}
/// Build the peek player content that slides in from the edge during drag
/// Uses shared MiniPlayerContent for consistency
Widget _buildPeekContent({
required BuildContext context,
required MusicAssistantProvider maProvider,
required dynamic peekPlayer,
required String? peekImageUrl,
required double slideOffset,
required double containerWidth,
required double artSize,
required double titleLeft,
required double titleTop,
required double titleWidth,
required double artistTop,
required double titleFontSize,
required double artistFontSize,
required Color backgroundColor,
required Color textColor,
required ColorScheme colorScheme,
}) {
// Calculate sliding position
// During transition (_inTransition = true), peek should be at center (offset 0)
// When sliding left (negative offset), peek comes from right
// When sliding right (positive offset), peek comes from left
double peekBaseOffset;
if (_inTransition && slideOffset.abs() < 0.01) {
// During transition with slideOffset at 0, show peek at center
peekBaseOffset = 0.0;
} else {
final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs();
// Position calculation:
// From right: starts at containerWidth (off right edge), moves to 0 as progress increases
// From left: starts at -containerWidth (off left edge), moves to 0 as progress increases
peekBaseOffset = isFromRight
? containerWidth * (1 - peekProgress) // Slides in from right
: -containerWidth * (1 - peekProgress); // Slides in from left
? containerWidth * (1 - peekProgress)
: -containerWidth * (1 - peekProgress);
}
// Check if peek player has a track - if not, show device info instead
// Check if peek player has a track
final hasTrack = _peekTrack != null && peekImageUrl != null;
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
return Stack(
children: [
// Peek album art / device icon
Positioned(
left: peekBaseOffset,
top: 0,
child: Container(
width: artSize,
height: artSize,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
),
child: hasTrack
? CachedNetworkImage(
imageUrl: peekImageUrl!,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
placeholder: (_, __) => _buildMiniPlaceholderArt(colorScheme),
errorWidget: (_, __, ___) => _buildMiniPlaceholderArt(colorScheme),
)
: _buildDeviceIcon(peekPlayer?.name ?? '', artSize, colorScheme),
),
),
// Peek track title / device name
Positioned(
left: titleLeft + peekBaseOffset,
top: titleTop,
child: SizedBox(
width: titleWidth,
child: Text(
peekTrackName,
style: TextStyle(
color: textColor,
fontSize: titleFontSize,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
// Peek artist name / device hint
Positioned(
left: titleLeft + peekBaseOffset,
top: artistTop,
child: SizedBox(
width: titleWidth,
child: Text(
peekArtistName,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: artistFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
/// Build device icon for non-playing peek player
Widget _buildDeviceIcon(String playerName, double size, ColorScheme colorScheme) {
final nameLower = playerName.toLowerCase();
IconData icon;
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
icon = Icons.phone_android_rounded;
} else if (nameLower.contains('group') || nameLower.contains('all')) {
icon = Icons.speaker_group_rounded;
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
icon = Icons.tv_rounded;
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
icon = Icons.cast_rounded;
} else {
icon = Icons.speaker_rounded;
}
return Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
icon,
color: colorScheme.onSurfaceVariant,
size: size * 0.5,
),
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: MiniPlayerContent(
primaryText: peekTrackName,
secondaryText: peekArtistName,
imageUrl: hasTrack ? peekImageUrl : null,
playerName: peekPlayer?.name ?? '',
backgroundColor: backgroundColor,
textColor: textColor,
width: containerWidth,
slideOffset: 0, // Transform handles positioning
),
);
}
/// Simplified placeholder for mini player peek
Widget _buildMiniPlaceholderArt(ColorScheme colorScheme) {
return Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note_rounded,
color: colorScheme.onSurfaceVariant,
size: 24,
),
);
}
/// Build peek player progress bar that slides in during swipe
Widget _buildPeekProgressBar({
+468 -22
View File
@@ -1,10 +1,15 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import 'expandable_player.dart';
import 'player/player_reveal_overlay.dart';
/// Cached color with contrast adjustment
/// Avoids expensive HSL conversions during scroll
@@ -111,13 +116,74 @@ class GlobalPlayerOverlay extends StatefulWidget {
static void showPlayer() {
_overlayStateKey.currentState?._setHidden(false);
}
/// Show the player reveal overlay with bounce animation
static void showPlayerReveal() {
_overlayStateKey.currentState?._showPlayerReveal();
}
/// Hide the player reveal overlay (no animation - used as callback from overlay)
static void hidePlayerReveal() {
_overlayStateKey.currentState?._hidePlayerReveal();
}
/// Dismiss the player reveal overlay with animation (for back gesture)
static void dismissPlayerReveal() {
_overlayStateKey.currentState?._dismissPlayerReveal();
}
/// Trigger single bounce on mini player (called when device selector collapses)
static void triggerBounce() {
_overlayStateKey.currentState?._triggerBounce();
}
/// Check if the player reveal is currently visible
static bool get isPlayerRevealVisible =>
_overlayStateKey.currentState?._isRevealVisible ?? false;
}
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
late AnimationController _slideController;
late Animation<double> _slideAnimation;
// Single bounce controller for device selector expand/collapse
late AnimationController _singleBounceController;
// Double bounce controller for hint
late AnimationController _doubleBounceController;
// State for player reveal overlay
bool _isRevealVisible = false;
// State for interactive hint mode (blur backdrop + wait for user action)
bool _isHintModeActive = false;
Timer? _hintBounceTimer;
// Track if player reveal was triggered from onboarding (for showing extra hints)
bool _isOnboardingReveal = false;
// Bounce offset for mini player (used by both single and double bounce)
final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
// Hint system state
// NOTE: AppStartup now handles the settings loading gate. It waits for:
// - First-time users: connected + player selected before showing HomeScreen
// - Returning users: connected before showing HomeScreen
// This eliminates the race condition that caused home screen flash.
bool _showHints = true;
bool _hasCompletedOnboarding = false; // First-use welcome screen
bool _hintTriggered = false; // Prevent multiple triggers per session
bool _settingsLoaded = false; // True once settings have been loaded from storage
bool _miniPlayerHintsReady = false; // True once connected with player (can show hints)
final _hintOpacityNotifier = ValueNotifier<double>(0.0);
// Welcome content fade-in animation
late AnimationController _welcomeFadeController;
// Key for the reveal overlay
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
@override
void initState() {
super.initState();
@@ -130,11 +196,135 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
// Single bounce for device selector expand/collapse
_singleBounceController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_singleBounceController.addListener(() {
final t = Curves.easeOut.transform(_singleBounceController.value);
// Single bounce: up to 10px then back to 0
if (t < 0.5) {
_bounceOffsetNotifier.value = 10.0 * (t * 2); // 0 -> 10
} else {
_bounceOffsetNotifier.value = 10.0 * ((1.0 - t) * 2); // 10 -> 0
}
});
// Hint bounce - single gentle bounce to draw attention
_doubleBounceController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_doubleBounceController.addListener(() {
final t = Curves.easeOut.transform(_doubleBounceController.value);
// Single bounce: up 20px then back down
if (t < 0.5) {
_bounceOffsetNotifier.value = 20.0 * (t * 2); // 0 -> 20
} else {
_bounceOffsetNotifier.value = 20.0 * ((1.0 - t) * 2); // 20 -> 0
}
});
// Welcome content fade-in
_welcomeFadeController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
// Load hint settings and start welcome immediately if needed
_loadHintSettings();
}
Future<void> _loadHintSettings() async {
_showHints = await SettingsService.getShowHints();
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!mounted) return;
setState(() {
_settingsLoaded = true;
});
// If first-time user and already connected (AppStartup waited for us),
// start welcome screen immediately - no race condition possible
if (!_hasCompletedOnboarding && !_hintTriggered) {
final provider = context.read<MusicAssistantProvider>();
if (provider.isConnected && provider.selectedPlayer != null) {
_miniPlayerHintsReady = true;
_startWelcomeScreen();
_startMiniPlayerBounce();
}
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Check if we should enable mini player hints now that we have connection
_checkConnectionForMiniPlayerHints();
}
/// Check connection state and start welcome screen if appropriate.
/// This is called from multiple places:
/// 1. didChangeDependencies() - when widget first builds
/// 2. _loadHintSettings() - when settings finish loading
/// 3. build() - when provider state changes (via post-frame callback)
void _checkConnectionForMiniPlayerHints() {
if (_hintTriggered || !mounted || !_settingsLoaded) return;
if (_hasCompletedOnboarding) return; // Not first-time user
final provider = context.read<MusicAssistantProvider>();
if (provider.isConnected && provider.selectedPlayer != null) {
// Connected with a player - start welcome screen now
_miniPlayerHintsReady = true;
_startWelcomeScreen();
// Start the bounce animation now that mini player is visible
_startMiniPlayerBounce();
}
}
/// Start the bounce animation for mini player hints
void _startMiniPlayerBounce() {
if (!_isHintModeActive || !mounted) return;
_doubleBounceController.reset();
_doubleBounceController.forward();
// Repeat bounce every 2 seconds
_hintBounceTimer?.cancel();
_hintBounceTimer = Timer.periodic(const Duration(seconds: 2), (_) {
if (_isHintModeActive && mounted) {
_doubleBounceController.reset();
_doubleBounceController.forward();
}
});
}
/// Start the welcome screen with fade-in animation
void _startWelcomeScreen() {
if (_hintTriggered) return;
_hintTriggered = true;
// Activate hint mode immediately (blur backdrop visible)
setState(() {
_isHintModeActive = true;
});
// Fade in welcome content (logo + title)
_welcomeFadeController.forward();
// Mini player bounce is started separately once connected via _startMiniPlayerBounce()
}
@override
void dispose() {
_hintBounceTimer?.cancel();
_slideController.dispose();
_singleBounceController.dispose();
_doubleBounceController.dispose();
_welcomeFadeController.dispose();
_bounceOffsetNotifier.dispose();
_hintOpacityNotifier.dispose();
super.dispose();
}
@@ -146,12 +336,121 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
}
}
void _showPlayerReveal() {
if (_isRevealVisible) return;
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
HapticFeedback.mediumImpact();
// Track if coming from hint mode (for onboarding hints in player selector)
final wasInHintMode = _isHintModeActive;
// End hint mode if active (user learned the gesture!)
_hintBounceTimer?.cancel();
_hintBounceTimer = null;
_hintOpacityNotifier.value = 0.0;
_isHintModeActive = false;
// Mark onboarding as completed if coming from welcome screen
if (wasInHintMode) {
SettingsService.setHasCompletedOnboarding(true);
}
// Trigger single bounce on expand
_singleBounceController.reset();
_singleBounceController.forward();
setState(() {
_isRevealVisible = true;
_isOnboardingReveal = wasInHintMode;
});
}
void _hidePlayerReveal() {
if (!_isRevealVisible) return;
_bounceOffsetNotifier.value = 0;
setState(() {
_isRevealVisible = false;
_isOnboardingReveal = false;
});
}
/// Dismiss with animation (for back gesture) - calls overlay's dismiss method
void _dismissPlayerReveal() {
if (!_isRevealVisible) return;
// Call the overlay's dismiss method which has the slide animation
_revealKey.currentState?.dismiss();
}
/// Trigger single bounce on mini player (called when device selector collapses)
void _triggerBounce() {
_singleBounceController.reset();
_singleBounceController.forward();
}
/// End hint mode (called when user taps skip button)
void _endHintMode() {
if (!_isHintModeActive) return;
_hintBounceTimer?.cancel();
_hintBounceTimer = null;
_hintOpacityNotifier.value = 0.0;
_bounceOffsetNotifier.value = 0.0;
// Mark onboarding as completed (first use only)
SettingsService.setHasCompletedOnboarding(true);
setState(() {
_isHintModeActive = false;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Stack(
// Watch provider to trigger rebuilds when connection state changes
final provider = context.watch<MusicAssistantProvider>();
// Compute whether this is a first-time user ready for welcome screen.
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
final isFirstTimeUserReady = _settingsLoaded &&
!_hasCompletedOnboarding &&
provider.isConnected &&
provider.selectedPlayer != null;
// Show welcome backdrop if:
// 1. Already in hint mode (_isHintModeActive), OR
// 2. First-time user ready but hint mode not started yet (!_hintTriggered)
// This ensures backdrop appears IMMEDIATELY when HomeScreen renders,
// preventing the flash. Welcome content fades in after via animation.
final shouldShowWelcomeBackdrop = _isHintModeActive ||
(isFirstTimeUserReady && !_hintTriggered);
// Trigger hint mode if conditions are met (for animations, bounce, etc)
if (isFirstTimeUserReady && !_hintTriggered) {
// Schedule for post-frame to avoid setState during build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_hintTriggered) {
_miniPlayerHintsReady = true;
_startWelcomeScreen();
_startMiniPlayerBounce();
}
});
}
// Handle back gesture at top level - dismiss hint mode or device list if visible
return PopScope(
canPop: !_isRevealVisible && !_isHintModeActive,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
if (_isHintModeActive) {
_endHintMode();
} else if (_isRevealVisible) {
_dismissPlayerReveal();
}
}
},
child: Stack(
children: [
// The main app content (Navigator, screens, etc.)
// Add bottom padding to account for bottom nav + mini player
@@ -160,14 +459,18 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
child: widget.child,
),
// Global persistent bottom navigation bar - positioned at bottom
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ListenableBuilder(
listenable: navigationProvider,
builder: (context, _) {
return Consumer<ThemeProvider>(
// Hide when: not connected, no player selected, OR showing welcome screen
// For first-time users, selectedPlayer becomes non-null at the same time
// as shouldShowWelcomeBackdrop becomes true, preventing any gap.
if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ListenableBuilder(
listenable: navigationProvider,
builder: (context, _) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
// Use adaptive primary color for bottom nav when adaptive theme is enabled
final sourceColor = themeProvider.adaptiveTheme
@@ -256,35 +559,178 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
);
},
);
},
},
),
),
),
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
// Blur backdrop for device selector (reveal mode) - static, no animation
if (_isRevealVisible && !_isHintModeActive)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _dismissPlayerReveal,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
color: colorScheme.surface.withOpacity(0.5),
),
),
),
),
// Welcome/hint backdrop - shows IMMEDIATELY when first-time user is ready.
// Uses computed `shouldShowWelcomeBackdrop` which is true BEFORE _isHintModeActive,
// preventing the 1-frame gap where home screen would be visible.
if (shouldShowWelcomeBackdrop)
Positioned.fill(
child: TweenAnimationBuilder<double>(
// Hold solid for 2s, then fade to 0.5 over 1s
tween: Tween<double>(begin: 1.0, end: 0.5),
duration: const Duration(seconds: 3),
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
builder: (context, opacity, child) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
// Use same dark color as connection screen for seamless transition
color: const Color(0xFF1a1a1a).withOpacity(opacity),
),
);
},
),
),
// Welcome message during hint mode - two positioned sections with fade-in
// Top section: Logo and Welcome title (stays fixed at top area)
if (_isHintModeActive)
Positioned(
left: 24,
right: 24,
// Position logo section high up - about 1/3 from top
top: MediaQuery.of(context).size.height * 0.15,
child: FadeTransition(
opacity: CurvedAnimation(
parent: _welcomeFadeController,
curve: Curves.easeOut,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Ensemble logo - same as settings screen
Image.asset(
'assets/images/ensemble_icon_transparent.png',
width: MediaQuery.of(context).size.width * 0.5,
fit: BoxFit.contain,
),
const SizedBox(height: 24),
// Welcome title
Text(
S.of(context)!.welcomeToEnsemble,
style: theme.textTheme.headlineSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
],
),
),
),
// Bottom section: Hint text and Skip button (only show once mini player is visible)
if (_isHintModeActive && _miniPlayerHintsReady)
Positioned(
left: 24,
right: 24,
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32,
child: FadeTransition(
opacity: CurvedAnimation(
parent: _welcomeFadeController,
curve: Curves.easeOut,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Hint text
Text(
S.of(context)!.welcomeMessage,
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.white,
fontSize: 16,
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Skip button - same gap above as below to mini player
TextButton(
onPressed: _endHintMode,
child: Text(
S.of(context)!.skip,
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
),
),
),
],
),
),
),
// Player reveal overlay - renders BELOW mini player so cards slide behind it
if (_isRevealVisible)
PlayerRevealOverlay(
key: _revealKey,
onDismiss: _hidePlayerReveal,
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
miniPlayerHeight: 64,
showOnboardingHints: _isOnboardingReveal,
),
// Global player overlay - renders ON TOP so cards slide behind it
// Use Selector instead of Consumer to avoid rebuilds during animation
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack})>(
selector: (_, provider) => (
isConnected: provider.isConnected,
hasPlayer: provider.selectedPlayer != null,
hasTrack: provider.currentTrack != null,
),
builder: (context, state, child) {
// Only show player if connected and has a selected player
if (!state.isConnected || !state.hasPlayer) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: _slideAnimation,
child: ExpandablePlayer(key: globalPlayerKey, slideOffset: 0),
builder: (context, staticChild) {
// Only slideOffset changes during animation, player widget stays same
return ExpandablePlayer(
key: globalPlayerKey,
slideOffset: _slideAnimation.value,
// Combine slide, bounce, and hint animations with ValueListenableBuilders
// This prevents full widget tree rebuilds - only ExpandablePlayer updates
return ValueListenableBuilder<double>(
valueListenable: _bounceOffsetNotifier,
builder: (context, bounceOffset, _) {
return ValueListenableBuilder<double>(
valueListenable: _hintOpacityNotifier,
builder: (context, hintOpacity, _) {
return AnimatedBuilder(
animation: _slideAnimation,
builder: (context, _) {
return ExpandablePlayer(
key: globalPlayerKey,
slideOffset: _slideAnimation.value,
bounceOffset: bounceOffset,
onRevealPlayers: _showPlayerReveal,
isDeviceRevealVisible: _isRevealVisible,
isHintVisible: hintOpacity > 0,
);
},
);
},
);
},
);
},
),
],
),
);
}
}
+25 -98
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart';
import 'mini_player_content.dart';
/// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget {
@@ -35,6 +35,8 @@ class DeviceSelectorBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final swipeHint = hasMultiplePlayers ? S.of(context)!.swipeToSwitchDevice : null;
return GestureDetector(
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
@@ -53,50 +55,19 @@ class DeviceSelectorBar extends StatelessWidget {
children: [
// Peek player content (shows when dragging)
if (slideOffset.abs() > 0.01 && peekPlayer != null)
_buildPeekContent(context),
_buildPeekContent(context, swipeHint),
// Current player content
Transform.translate(
offset: Offset(slideOffset * width, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedPlayer.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasMultiplePlayers)
Text(
S.of(context)!.swipeToSwitchDevice,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
),
),
],
),
),
],
),
),
MiniPlayerContent(
primaryText: selectedPlayer.name,
secondaryText: swipeHint,
imageUrl: null,
playerName: selectedPlayer.name,
backgroundColor: backgroundColor,
textColor: textColor,
width: width,
slideOffset: slideOffset,
isHint: swipeHint != null,
),
],
),
@@ -107,7 +78,7 @@ class DeviceSelectorBar extends StatelessWidget {
}
/// Build peek player content that slides in from the edge
Widget _buildPeekContent(BuildContext context) {
Widget _buildPeekContent(BuildContext context, String? swipeHint) {
final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs();
@@ -118,61 +89,17 @@ class DeviceSelectorBar extends StatelessWidget {
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: Padding(
padding: Spacing.paddingH16,
child: Row(
children: [
Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor,
size: IconSizes.md,
),
Spacing.hGap12,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
peekPlayer.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasMultiplePlayers)
Text(
S.of(context)!.swipeToSwitchDevice,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
),
),
],
),
),
],
),
child: MiniPlayerContent(
primaryText: peekPlayer.name,
secondaryText: swipeHint,
imageUrl: null,
playerName: peekPlayer.name,
backgroundColor: backgroundColor,
textColor: textColor,
width: width,
slideOffset: 0, // No additional slide - Transform handles positioning
isHint: swipeHint != null,
),
);
}
/// Get appropriate icon for player based on name
IconData _getPlayerIcon(String playerName) {
final nameLower = playerName.toLowerCase();
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
return Icons.phone_android_rounded;
} else if (nameLower.contains('group')) {
return Icons.speaker_group_rounded;
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
return Icons.tv_rounded;
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
return Icons.cast_rounded;
} else {
return Icons.speaker_rounded;
}
}
}
+216
View File
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
/// Shared constants for mini player layout
class MiniPlayerLayout {
static const double height = 64.0;
static const double artSize = 64.0;
static const double textLeftOffset = 10.0; // Gap between art and text
static const double textLeft = artSize + textLeftOffset; // 74px
static const double primaryTop = 13.0;
static const double secondaryTop = 33.0;
static const double textRightPadding = 12.0;
static const double iconSize = 28.0;
static const double iconOpacity = 0.4;
static const double secondaryTextOpacity = 0.6;
static const double primaryFontSize = 16.0;
static const double secondaryFontSize = 14.0;
static const FontWeight primaryFontWeight = FontWeight.w500;
}
/// Unified mini player content widget used by:
/// - DeviceSelectorBar (non-playing state)
/// - ExpandablePlayer collapsed state (playing state)
/// - Peek content during swipe gestures
class MiniPlayerContent extends StatelessWidget {
/// Primary text line (track name or device name)
final String primaryText;
/// Secondary text line (artist name or "Swipe to switch device")
/// If null, primary text will be vertically centered
final String? secondaryText;
/// Album art URL - if null, shows device icon
final String? imageUrl;
/// Player name for icon selection when no image
final String playerName;
/// Background color for the content area
final Color backgroundColor;
/// Text color
final Color textColor;
/// Width of the content area
final double width;
/// Horizontal slide offset for swipe animation (-1 to 1)
final double slideOffset;
/// Whether secondary text is a hint (shows lightbulb icon)
final bool isHint;
/// Whether to show the progress bar overlay (for playing state)
final bool showProgress;
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
final double progress;
const MiniPlayerContent({
super.key,
required this.primaryText,
this.secondaryText,
this.imageUrl,
required this.playerName,
required this.backgroundColor,
required this.textColor,
required this.width,
this.slideOffset = 0.0,
this.isHint = false,
this.showProgress = false,
this.progress = 0.0,
});
@override
Widget build(BuildContext context) {
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
final slidePixels = slideOffset * width;
// Calculate text width (leaves room for right padding)
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
// Darkened background for icon area (matches DeviceSelectorBar)
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
return SizedBox(
width: width,
height: MiniPlayerLayout.height,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
// Progress bar background (only for playing state)
if (showProgress && progress > 0)
Positioned(
left: MiniPlayerLayout.artSize + slidePixels,
top: 0,
bottom: 0,
width: (width - MiniPlayerLayout.artSize) * progress,
child: Container(color: backgroundColor),
),
// Art / Icon area
Positioned(
left: slidePixels,
top: 0,
child: SizedBox(
width: MiniPlayerLayout.artSize,
height: MiniPlayerLayout.artSize,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildIconArea(iconAreaBackground),
errorWidget: (_, __, ___) => _buildIconArea(iconAreaBackground),
)
: _buildIconArea(iconAreaBackground),
),
),
// Primary text line
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: hasSecondaryLine
? MiniPlayerLayout.primaryTop
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
right: MiniPlayerLayout.textRightPadding - slidePixels,
child: Text(
primaryText,
style: TextStyle(
color: textColor,
fontSize: MiniPlayerLayout.primaryFontSize,
fontWeight: MiniPlayerLayout.primaryFontWeight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Secondary text line (with optional hint icon)
if (hasSecondaryLine)
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: MiniPlayerLayout.secondaryTop,
right: MiniPlayerLayout.textRightPadding - slidePixels,
child: isHint
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lightbulb_outline,
size: 14,
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
),
const SizedBox(width: 4),
Flexible(
child: Text(
secondaryText!,
style: TextStyle(
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
fontSize: MiniPlayerLayout.secondaryFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
)
: Text(
secondaryText!,
style: TextStyle(
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
fontSize: MiniPlayerLayout.secondaryFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
/// Build the icon area with device-appropriate icon
Widget _buildIconArea(Color background) {
return Container(
color: background,
child: Center(
child: Icon(
_getPlayerIcon(playerName),
color: textColor.withOpacity(MiniPlayerLayout.iconOpacity),
size: MiniPlayerLayout.iconSize,
),
),
);
}
/// Get appropriate icon based on player name
static IconData _getPlayerIcon(String playerName) {
final nameLower = playerName.toLowerCase();
if (nameLower.contains('phone') || nameLower.contains('ensemble') || nameLower.contains('mobile')) {
return Icons.phone_android_rounded;
} else if (nameLower.contains('group') || nameLower.contains('sync') || nameLower.contains('all')) {
return Icons.speaker_group_rounded;
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
return Icons.tv_rounded;
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
return Icons.cast_rounded;
} else {
return Icons.speaker_rounded;
}
}
}
+244
View File
@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
import '../../l10n/app_localizations.dart';
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
class PlayerCard extends StatelessWidget {
final dynamic player;
final dynamic trackInfo;
final String? albumArtUrl;
final bool isSelected;
final bool isPlaying;
final bool isGrouped;
final Color backgroundColor;
final Color textColor;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final VoidCallback? onPlayPause;
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
// Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D);
const PlayerCard({
super.key,
required this.player,
this.trackInfo,
this.albumArtUrl,
required this.isSelected,
required this.isPlaying,
this.isGrouped = false,
required this.backgroundColor,
required this.textColor,
required this.onTap,
this.onLongPress,
this.onPlayPause,
this.onSkipNext,
this.onPower,
});
@override
Widget build(BuildContext context) {
// Match mini player dimensions
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
const double artSize = cardHeight;
const double borderRadius = Radii.xl; // 16px - same as mini player
return GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Container(
height: cardHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
border: isGrouped
? Border.all(color: groupBorderColor, width: 1.5)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
// Album art or speaker icon - same size for consistent text alignment
SizedBox(
width: artSize,
height: artSize,
child: albumArtUrl != null
? CachedNetworkImage(
imageUrl: albumArtUrl!,
fit: BoxFit.cover,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildSpeakerIcon(),
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
)
: _buildSpeakerIcon(),
),
// Player info
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Player name
Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getStatusColor(),
),
),
Expanded(
child: Text(
player.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 2),
// Track name or status
Text(
_getSubtitle(context),
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
// Transport controls - compact sizing to align with mini player
// Play/Pause and Next only shown when powered with content
if (player.available && player.powered && trackInfo != null) ...[
// Play/Pause - slight nudge right
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor,
size: 28,
),
onPressed: onPlayPause,
padding: EdgeInsets.zero,
),
),
),
// Skip next - nudged right to close gap with power
Transform.translate(
offset: const Offset(6, 0),
child: IconButton(
icon: Icon(
Icons.skip_next_rounded,
color: textColor,
size: 28,
),
onPressed: onSkipNext,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
// Power button - smallest
if (player.available)
IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: player.powered ? textColor : textColor.withOpacity(0.5),
size: 20,
),
onPressed: onPower,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
),
),
);
}
Widget _buildSpeakerIcon() {
// Darker shade of the card background to match album art square
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
return Container(
color: iconBgColor,
child: Center(
child: Icon(
Icons.speaker_rounded,
color: textColor.withOpacity(0.4),
size: 28,
),
),
);
}
Color _getStatusColor() {
if (!player.available) {
return Colors.grey.withOpacity(0.5);
}
if (!player.powered) {
return Colors.grey;
}
if (isPlaying) {
return Colors.green;
}
if (trackInfo != null) {
return Colors.orange; // Has content but paused
}
return Colors.grey.shade400; // Idle
}
String _getSubtitle(BuildContext context) {
if (!player.available) {
return S.of(context)!.playerStateUnavailable;
}
if (!player.powered) {
return S.of(context)!.playerStateOff;
}
if (trackInfo != null) {
return trackInfo.name ?? S.of(context)!.playerStateIdle;
}
return S.of(context)!.playerStateIdle;
}
}
+16 -18
View File
@@ -55,14 +55,13 @@ class PlayerControls extends StatelessWidget {
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [
// Shuffle (expanded only)
if (isExpanded)
Opacity(
opacity: expandedElementsOpacity,
child: _buildSecondaryButton(
icon: Icons.shuffle_rounded,
color: shuffle == true ? primaryColor : textColor.withOpacity(0.5),
onPressed: isLoadingQueue ? null : onToggleShuffle,
),
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
if (isExpanded && expandedElementsOpacity > 0.1)
_buildSecondaryButton(
icon: Icons.shuffle_rounded,
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onToggleShuffle,
),
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
@@ -90,17 +89,16 @@ class PlayerControls extends StatelessWidget {
),
// Repeat (expanded only)
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
if (isExpanded)
Opacity(
opacity: expandedElementsOpacity,
child: _buildSecondaryButton(
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
color: repeatMode != null && repeatMode != 'off'
? primaryColor
: textColor.withOpacity(0.5),
onPressed: isLoadingQueue ? null : onCycleRepeat,
),
if (isExpanded && expandedElementsOpacity > 0.1)
_buildSecondaryButton(
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
color: (repeatMode != null && repeatMode != 'off'
? primaryColor
: textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onCycleRepeat,
),
],
);
@@ -0,0 +1,377 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/music_assistant_provider.dart';
import '../../services/settings_service.dart';
import '../../theme/design_tokens.dart';
import '../../theme/theme_provider.dart';
import '../../theme/palette_helper.dart';
import '../../constants/timings.dart';
import '../global_player_overlay.dart';
import 'player_card.dart';
/// Overlay that reveals all available players with a staggered slide-up animation.
/// Triggered by swiping down on the mini player or tapping the device button.
class PlayerRevealOverlay extends StatefulWidget {
final VoidCallback onDismiss;
final double miniPlayerBottom; // Bottom position of mini player
final double miniPlayerHeight;
final bool showOnboardingHints; // Show additional hints for first-time users
const PlayerRevealOverlay({
super.key,
required this.onDismiss,
required this.miniPlayerBottom,
required this.miniPlayerHeight,
this.showOnboardingHints = false,
});
@override
State<PlayerRevealOverlay> createState() => PlayerRevealOverlayState();
}
class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
with TickerProviderStateMixin {
late AnimationController _revealController;
late Animation<double> _revealAnimation;
late Animation<double> _backdropAnimation;
Timer? _refreshTimer;
// Track vertical drag for dismissal
double _dragOffset = 0;
bool _isDragging = false;
// Cache extracted colors per player for per-device accent colors
final Map<String, ColorScheme?> _playerColors = {};
// Hint system
bool _showHints = true;
@override
void initState() {
super.initState();
// Load hint settings
SettingsService.getShowHints().then((value) {
if (mounted) setState(() => _showHints = value);
});
_revealController = AnimationController(
vsync: this,
);
// Quick smooth animation with subtle overshoot
_revealAnimation = CurvedAnimation(
parent: _revealController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_backdropAnimation = CurvedAnimation(
parent: _revealController,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
);
// Preload all player track info and extract colors
_preloadColorsForPlayers();
// Start reveal animation
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
// Auto-refresh player data
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
if (mounted) {
final provider = context.read<MusicAssistantProvider>();
provider.refreshPlayers();
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
}
});
}
/// Extract accent colors from album art for each player
Future<void> _preloadColorsForPlayers() async {
final maProvider = context.read<MusicAssistantProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
await maProvider.preloadAllPlayerTracks();
final players = maProvider.availablePlayers;
for (final player in players) {
if (_playerColors.containsKey(player.playerId)) continue;
final track = maProvider.getCachedTrackForPlayer(player.playerId);
if (track == null) continue;
final imageUrl = maProvider.getImageUrl(track, size: 128);
if (imageUrl == null) continue;
try {
final imageProvider = NetworkImage(imageUrl);
final schemes = await PaletteHelper.extractColorSchemes(imageProvider);
if (mounted && schemes != null) {
final colorScheme = isDark ? schemes.$2 : schemes.$1;
setState(() {
_playerColors[player.playerId] = colorScheme;
});
}
} catch (_) {
// Ignore color extraction errors
}
}
}
@override
void dispose() {
_revealController.dispose();
_refreshTimer?.cancel();
super.dispose();
}
/// Animate reveal (called from parent)
void reveal() {
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
}
/// Animate dismissal and call callback when done
void dismiss() {
// Trigger single bounce on mini player when cards are ~70% collapsed
// This creates the effect of cards "landing" on the mini player
bool bounceFired = false;
void checkBounce() {
if (!bounceFired && _revealController.value < 0.3) {
bounceFired = true;
GlobalPlayerOverlay.triggerBounce();
}
}
_revealController.duration = const Duration(milliseconds: 150);
_revealController.addListener(checkBounce);
_revealController.reverse().then((_) {
_revealController.removeListener(checkBounce);
widget.onDismiss();
});
}
void _handleVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
_isDragging = true;
// Negate delta so swipe down = cards move down (toward dismissal)
_dragOffset -= details.primaryDelta ?? 0;
// Clamp: allow moving down (positive) freely, limit moving up to 20px
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
});
}
void _handleVerticalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
HapticFeedback.lightImpact();
// Reset drag offset before dismiss so animation starts from correct position
setState(() {
_isDragging = false;
_dragOffset = 0;
});
dismiss();
} else {
// Spring back
setState(() {
_isDragging = false;
_dragOffset = 0;
});
}
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Back gesture is handled at GlobalPlayerOverlay level
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final allPlayers = maProvider.availablePlayers;
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
// Filter out the selected player - it's already shown in the mini player
final players = allPlayers
.where((p) => p.playerId != selectedPlayer?.playerId)
.toList();
// Default card colors - used when player doesn't have extracted colors
final defaultBgColor = colorScheme.primaryContainer;
final defaultTextColor = colorScheme.onPrimaryContainer;
// Calculate total stack height for animation
const cardHeight = 64.0;
const cardSpacing = 12.0;
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
final t = _revealAnimation.value;
return Stack(
children: [
// Backdrop - tap to dismiss (darkness handled by GlobalPlayerOverlay)
Positioned.fill(
child: GestureDetector(
onTap: dismiss,
behavior: HitTestBehavior.opaque,
child: const SizedBox.expand(),
),
),
// Player cards - positioned above mini player
Positioned(
left: 12,
right: 12,
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
child: GestureDetector(
onVerticalDragUpdate: _handleVerticalDragUpdate,
onVerticalDragEnd: _handleVerticalDragEnd,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Hint above player list - different for onboarding vs regular use
// Onboarding: show first-time hint (how to select)
// Regular: show sync hint (tap and hold to sync)
if (players.isNotEmpty && (_showHints || widget.showOnboardingHints))
Builder(
builder: (context) {
// Calculate slide offset to match the topmost card
const baseOffset = 80.0;
final hintDistanceToTravel = baseOffset + (players.length * (cardHeight + cardSpacing));
final hintSlideOffset = hintDistanceToTravel * (1.0 - t);
// Show different hint for onboarding vs regular use
final isOnboarding = widget.showOnboardingHints;
final hintText = isOnboarding
? S.of(context)!.selectPlayerHint
: S.of(context)!.holdToSync;
return Transform.translate(
offset: Offset(0, hintSlideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Material(
type: MaterialType.transparency,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isOnboarding ? Icons.touch_app_outlined : Icons.lightbulb_outline,
size: 18,
color: colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 6),
Text(
hintText,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
},
),
// Build player cards - all slide from behind mini player
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
// Get track info for this player
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
// Get album art URL - use track directly like mini player does
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
// Use per-player colors if available, otherwise use defaults
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// Animation: all cards start hidden behind mini player
// and fan out to their final positions with spring physics
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
// Use the elastic animation value directly (already has bounce)
final slideOffset = distanceToTravel * (1.0 - t);
return Transform.translate(
offset: Offset(0, slideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: player.isGrouped,
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
dismiss();
},
onLongPress: () {
HapticFeedback.mediumImpact();
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Syncing ${player.name}...'),
duration: const Duration(seconds: 2),
),
);
maProvider.togglePlayerSync(player.playerId);
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
}
},
onSkipNext: () {
maProvider.nextTrack(player.playerId);
},
onPower: () {
maProvider.togglePower(player.playerId);
},
),
),
);
}),
],
),
),
),
],
);
},
);
},
);
}
}
+5
View File
@@ -118,6 +118,11 @@ class QueuePanel extends StatelessWidget {
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
memCacheWidth: 176,
memCacheHeight: 176,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container(
color: textColor.withOpacity(0.1),
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
+136 -69
View File
@@ -1,9 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../constants/timings.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer
@@ -17,40 +19,60 @@ class PlayerSelector extends StatelessWidget {
final availablePlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing (excluding selected player)
// User already sees if selected player is playing, badge shows "other" activity
final selectedPlayerId = selectedPlayer?.playerId;
final playingCount = availablePlayers
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
.length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Material(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
child: Badge(
isLabelVisible: playingCount > 0,
backgroundColor: colorScheme.tertiary,
textColor: colorScheme.onTertiary,
label: Text(
playingCount.toString(),
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 11,
color: colorScheme.onTertiary,
),
),
child: Material(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (selectedPlayer != null) ...[
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
selectedPlayer.name,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
child: InkWell(
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (selectedPlayer != null) ...[
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
selectedPlayer.name,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 8),
],
Icon(
MdiIcons.castAudio,
color: colorScheme.onPrimaryContainer,
size: 20,
),
const SizedBox(width: 8),
],
Icon(
MdiIcons.castAudio,
color: colorScheme.onPrimaryContainer,
size: 20,
),
],
),
),
),
),
@@ -63,27 +85,8 @@ class PlayerSelector extends StatelessWidget {
MusicAssistantProvider provider,
List players,
) {
// Collapse player if expanded before showing sheet
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
// Show the bottom sheet
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
isDismissible: true,
builder: (context) {
return _PlayerSelectorSheet();
},
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
// Use the new player reveal animation
GlobalPlayerOverlay.showPlayerReveal();
}
}
@@ -182,7 +185,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
child: currentPlayers.isEmpty
? Center(
child: Text(
'No players available',
S.of(context)!.noPlayersAvailable,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
),
)
@@ -199,23 +202,34 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
// but still have track info - treat as "has content" for display
final isIdle = player.state == 'idle';
// Check for external source (optical, Spotify, etc.)
final isExternalSource = player.isExternalSource;
// Get track info - use current track for selected player, cache for others
final playerTrack = isSelected
? currentTrack
: maProvider.getCachedTrackForPlayer(player.playerId);
// Skip track info for external sources
final playerTrack = isExternalSource
? null
: (isSelected
? currentTrack
: maProvider.getCachedTrackForPlayer(player.playerId));
// Player has content if playing, paused, or idle with cached track
final hasContent = isPlaying || isPaused || (isIdle && playerTrack != null);
// Player has content if playing, paused, or idle with cached track (but not external source)
final hasContent = !isExternalSource && (isPlaying || isPaused || (isIdle && playerTrack != null));
// Get album art for any player with content
// Get album art for any player with content (skip for external source)
String? albumArtUrl;
if (playerTrack != null && isOn && hasContent) {
if (!isExternalSource && playerTrack != null && isOn && hasContent) {
albumArtUrl = maProvider.getImageUrl(
playerTrack.album ?? playerTrack,
size: 128,
);
}
// Check if this player is part of a sync group
final isGrouped = player.isGrouped;
// Pastel yellow for grouped players
const groupBorderColor = Color(0xFFFFF59D);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
@@ -223,6 +237,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
maProvider.selectPlayer(player);
Navigator.pop(context);
},
onLongPress: () {
// Haptic feedback for sync action
HapticFeedback.mediumImpact();
// Show snackbar for visual feedback
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Syncing ${player.name}...'),
duration: const Duration(seconds: 2),
),
);
// Long-press to sync/unsync player
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
maProvider.togglePlayerSync(player.playerId);
},
borderRadius: BorderRadius.circular(16),
child: Container(
height: 72,
@@ -231,6 +262,9 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? colorScheme.primary.withOpacity(0.15)
: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(16),
border: isGrouped
? Border.all(color: groupBorderColor, width: 1.5)
: null,
),
child: Row(
children: [
@@ -293,23 +327,27 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? colorScheme.onSurface.withOpacity(0.24)
: !isOn
? colorScheme.onSurface.withOpacity(0.3)
: isPlaying
? Colors.green
: hasContent
? Colors.orange // paused or idle with content
: colorScheme.onSurfaceVariant.withOpacity(0.5),
: isExternalSource
? Colors.cyan // external source (optical, Spotify, etc.)
: isPlaying
? Colors.green
: hasContent
? Colors.orange // paused or idle with content
: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
!player.available
? 'Unavailable'
? S.of(context)!.playerStateUnavailable
: !isOn
? 'Off'
: hasContent && playerTrack != null
? playerTrack.name
: 'Idle',
? S.of(context)!.playerStateOff
: isExternalSource
? S.of(context)!.playerStateExternalSource
: hasContent && playerTrack != null
? playerTrack.name
: S.of(context)!.playerStateIdle,
style: TextStyle(
color: player.available
? colorScheme.onSurfaceVariant
@@ -320,6 +358,35 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
overflow: TextOverflow.ellipsis,
),
),
if (isGrouped) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: groupBorderColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.link_rounded,
size: 10,
color: Colors.amber.shade800,
),
const SizedBox(width: 3),
Text(
'Synced',
style: TextStyle(
color: Colors.amber.shade800,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
if (isSelected) ...[
const SizedBox(width: 8),
Container(
@@ -329,7 +396,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Selected',
S.of(context)!.playerSelected,
style: TextStyle(
color: colorScheme.primary,
fontSize: 10,
@@ -343,8 +410,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
],
),
),
// Play/Pause button
if (player.available && isOn)
// Play/Pause button (hidden for external sources - controls don't work)
if (player.available && isOn && !isExternalSource)
IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
+44 -2
View File
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../screens/audiobook_series_screen.dart';
import '../utils/page_transitions.dart';
class SeriesRow extends StatefulWidget {
final String title;
@@ -267,10 +268,11 @@ class _SeriesCard extends StatelessWidget {
return GestureDetector(
onTap: () {
// PERF: Use FadeSlidePageRoute for consistent hero animation timing
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AudiobookSeriesScreen(
FadeSlidePageRoute(
child: AudiobookSeriesScreen(
series: series,
heroTag: heroTag,
initialCovers: covers,
@@ -284,6 +286,44 @@ class _SeriesCard extends StatelessWidget {
// Use AspectRatio to guarantee square cover
Hero(
tag: heroTag,
// PERF: Use flightShuttleBuilder to avoid animating complex grid
// during hero flight. Show cached first cover image instead.
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
// Use first cover as simple placeholder during flight
final heroChild = toContext.widget as Hero;
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return ClipRRect(
borderRadius: BorderRadius.circular(8 + 4 * animation.value),
child: child,
);
},
child: covers != null && covers!.isNotEmpty
? CachedNetworkImage(
imageUrl: covers!.first,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: colorScheme.surfaceContainerHighest,
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.collections_bookmark_rounded,
size: 48,
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
),
);
},
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
@@ -373,6 +413,8 @@ class _SeriesCard extends StatelessWidget {
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
color: colorScheme.surfaceContainerHighest,
),
+3 -1
View File
@@ -186,7 +186,9 @@ class _TrackCard extends StatelessWidget {
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: const Duration(milliseconds: 150),
// PERF: Duration.zero for smooth scrolling
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Icon(
Icons.music_note_rounded,
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.3.1-beta+23
version: 2.7.0-beta+30
environment:
sdk: '>=3.0.0 <4.0.0'