Compare commits

..
103 Commits
Author SHA1 Message Date
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
50 changed files with 3794 additions and 895 deletions
+245 -3
View File
@@ -93,12 +93,87 @@ 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};
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 3;
@override
MigrationStrategy get migration {
@@ -107,7 +182,16 @@ 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);
}
},
);
}
@@ -327,4 +411,162 @@ 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();
}
}
+15 -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}",
@@ -269,8 +270,21 @@
"audiobookSingular": "Hörbuch",
"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",
"playerSelected": "Ausgewählt"
"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"
}
+101 -1
View File
@@ -635,6 +635,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",
@@ -1073,7 +1078,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"
},
@@ -1143,6 +1148,36 @@
"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"
@@ -1161,5 +1196,70 @@
"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"
}
}
+16 -2
View File
@@ -225,6 +225,7 @@
}
},
"playingTrack": "Reproduciendo canción",
"nowPlaying": "Reproduciendo ahora",
"markedAsFinished": "{name} marcado como terminado",
"@markedAsFinished": {
"placeholders": {
@@ -433,7 +434,7 @@
"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 de dispositivo",
"swipeToSwitchDevice": "Desliza para cambiar dispositivo",
"chapterNumber": "Capítulo {number}",
"@chapterNumber": {
"placeholders": {
@@ -465,8 +466,21 @@
}
},
"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",
"playerSelected": "Seleccionado"
"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"
}
+57 -8
View File
@@ -230,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(
@@ -348,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});
@@ -361,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();
}
@@ -388,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;
@@ -422,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,
+25
View File
@@ -12,6 +12,8 @@ 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)
Player({
required this.playerId,
@@ -25,6 +27,8 @@ class Player {
this.volumeMuted,
this.elapsedTime,
this.elapsedTimeLastUpdated,
this.groupMembers,
this.syncedTo,
});
/// Create a copy of this Player with some fields replaced
@@ -40,6 +44,8 @@ class Player {
bool? volumeMuted,
double? elapsedTime,
double? elapsedTimeLastUpdated,
List<String>? groupMembers,
String? syncedTo,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -53,6 +59,8 @@ class Player {
volumeMuted: volumeMuted ?? this.volumeMuted,
elapsedTime: elapsedTime ?? this.elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
groupMembers: groupMembers ?? this.groupMembers,
syncedTo: syncedTo ?? this.syncedTo,
);
}
@@ -61,6 +69,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 +178,13 @@ 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?;
return Player(
playerId: json['player_id'] as String,
name: json['name'] as String,
@@ -176,6 +197,8 @@ class Player {
volumeMuted: json['volume_muted'] as bool?,
elapsedTime: elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
groupMembers: groupMembers,
syncedTo: syncedTo,
);
}
@@ -192,6 +215,8 @@ 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,
};
}
}
+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
+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,
);
+87 -36
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');
@@ -427,20 +439,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 +553,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 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: AlbumDetailsScreen(
album: album,
heroTagSuffix: heroSuffix,
initialImageUrl: imageUrl,
),
),
);
@@ -833,12 +879,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 +988,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 +1029,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,
),
),
);
+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);
}
}
+10 -2
View File
@@ -200,7 +200,12 @@ class SearchScreenState extends State<SearchScreen> {
Widget _buildSearchContent() {
final colorScheme = Theme.of(context).colorScheme;
if (_isSearching) {
// Show cached results even while searching - only show spinner if no cached results
final hasCachedResults = _searchResults['artists']?.isNotEmpty == true ||
_searchResults['albums']?.isNotEmpty == true ||
_searchResults['tracks']?.isNotEmpty == true;
if (_isSearching && !hasCachedResults) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
@@ -491,7 +496,10 @@ class SearchScreenState extends State<SearchScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AlbumDetailsScreen(album: album),
builder: (context) => AlbumDetailsScreen(
album: album,
initialImageUrl: imageUrl,
),
),
);
},
+243 -53
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,7 +426,137 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16),
// Language picker
// 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),
),
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),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return 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)!.materialYou,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
onChanged: (value) {
themeProvider.setUseMaterialTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return 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)!.adaptiveTheme,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.extractColorsFromArtwork,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.adaptiveTheme,
onChanged: (value) {
themeProvider.setAdaptiveTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
),
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) {
@@ -493,64 +639,108 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
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),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return 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)!.materialYou,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
onChanged: (value) {
themeProvider.setUseMaterialTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
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),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return 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)!.adaptiveTheme,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.extractColorsFromArtwork,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.adaptiveTheme,
onChanged: (value) {
themeProvider.setAdaptiveTheme(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
);
},
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),
+83
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)
@@ -276,4 +285,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');
}
}();
}
}
+83
View File
@@ -156,4 +156,87 @@ 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();
}
+77 -15
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');
@@ -1973,6 +1996,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 {
+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;
+4 -3
View File
@@ -518,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',
@@ -580,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,
+199 -203
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.
///
@@ -31,11 +32,21 @@ class ExpandablePlayer extends StatefulWidget {
/// 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
@@ -73,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;
@@ -150,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
@@ -320,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 {
@@ -417,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;
@@ -446,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');
}
@@ -479,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) {
@@ -1101,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);
@@ -1174,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);
@@ -1299,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(
@@ -1315,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
@@ -1357,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
@@ -1428,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,
@@ -1455,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,
),
@@ -1846,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({
+385 -51
View File
@@ -1,9 +1,12 @@
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';
@@ -129,7 +132,7 @@ class GlobalPlayerOverlay extends StatefulWidget {
_overlayStateKey.currentState?._dismissPlayerReveal();
}
/// Trigger the bounce animation on mini player (called when overlay dismiss starts)
/// Trigger single bounce on mini player (called when device selector collapses)
static void triggerBounce() {
_overlayStateKey.currentState?._triggerBounce();
}
@@ -144,13 +147,39 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
late AnimationController _slideController;
late Animation<double> _slideAnimation;
// Controller for player reveal animation with bounce
late AnimationController _bounceController;
late Animation<double> _bounceAnimation;
// 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;
double _bounceOffset = 0.0;
// 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>();
@@ -168,30 +197,134 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
reverseCurve: Curves.easeInCubic,
);
// Bounce animation - quick dip down then back up
_bounceController = AnimationController(
duration: const Duration(milliseconds: 200),
// Single bounce for device selector expand/collapse
_singleBounceController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_bounceAnimation = CurvedAnimation(
parent: _bounceController,
curve: Curves.easeOut,
_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,
);
_bounceController.addListener(() {
setState(() {
// Quick dip: goes down 10px at peak (0.5), then back to 0
final t = _bounceAnimation.value;
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
_bounceOffset = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
});
// 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();
_bounceController.dispose();
_singleBounceController.dispose();
_doubleBounceController.dispose();
_welcomeFadeController.dispose();
_bounceOffsetNotifier.dispose();
_hintOpacityNotifier.dispose();
super.dispose();
}
@@ -209,18 +342,37 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
GlobalPlayerOverlay.collapsePlayer();
}
HapticFeedback.mediumImpact();
_bounceController.reset();
// 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;
});
_bounceController.forward();
}
void _hidePlayerReveal() {
if (!_isRevealVisible) return;
_bounceOffsetNotifier.value = 0;
setState(() {
_isRevealVisible = false;
_bounceOffset = 0;
_isOnboardingReveal = false;
});
}
@@ -228,14 +380,27 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
void _dismissPlayerReveal() {
if (!_isRevealVisible) return;
// Call the overlay's dismiss method which has the slide animation
// Note: dismiss() triggers the bounce animation internally
_revealKey.currentState?.dismiss();
}
/// Trigger bounce animation on mini player
/// Trigger single bounce on mini player (called when device selector collapses)
void _triggerBounce() {
_bounceController.reset();
_bounceController.forward();
_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
@@ -243,12 +408,46 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// Handle back gesture at top level - dismiss device list if visible
// 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,
canPop: !_isRevealVisible && !_isHintModeActive,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isRevealVisible) {
_dismissPlayerReveal();
if (!didPop) {
if (_isHintModeActive) {
_endHintMode();
} else if (_isRevealVisible) {
_dismissPlayerReveal();
}
}
},
child: Stack(
@@ -260,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
@@ -356,9 +559,125 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
);
},
);
},
},
),
),
),
// 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(
@@ -366,30 +685,45 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
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,
bounceOffset: _bounceOffset,
onRevealPlayers: _showPlayerReveal,
// 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 -115
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import 'mini_player_content.dart';
/// A compact device selector bar shown when no track is playing
class DeviceSelectorBar extends StatelessWidget {
@@ -34,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,
@@ -52,59 +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: Row(
children: [
// Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(selectedPlayer.name),
color: textColor.withOpacity(0.4),
size: 28,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
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,
),
],
),
@@ -115,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();
@@ -126,70 +89,17 @@ class DeviceSelectorBar extends StatelessWidget {
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: Row(
children: [
// Speaker icon - same size as album art for consistent text alignment
SizedBox(
width: height,
height: height,
child: Container(
color: Color.lerp(backgroundColor, Colors.black, 0.15),
child: Center(
child: Icon(
_getPlayerIcon(peekPlayer.name),
color: textColor.withOpacity(0.4),
size: 28,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
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;
}
}
}
+11
View File
@@ -12,13 +12,18 @@ class PlayerCard extends StatelessWidget {
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,
@@ -26,9 +31,11 @@ class PlayerCard extends StatelessWidget {
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,
@@ -43,11 +50,15 @@ class PlayerCard extends StatelessWidget {
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),
+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,
),
],
);
+75 -5
View File
@@ -2,7 +2,9 @@ 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';
@@ -16,12 +18,14 @@ 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
@@ -43,10 +47,18 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// 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,
);
@@ -127,7 +139,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
/// Animate dismissal and call callback when done
void dismiss() {
// Trigger settle bounce on mini player when cards are ~70% collapsed
// 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() {
@@ -210,13 +222,12 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
return Stack(
children: [
// Backdrop - tap to dismiss
// Backdrop - tap to dismiss (darkness handled by GlobalPlayerOverlay)
Positioned.fill(
child: GestureDetector(
onTap: dismiss,
child: Container(
color: Colors.black.withOpacity(0.6 * _backdropAnimation.value),
),
behavior: HitTestBehavior.opaque,
child: const SizedBox.expand(),
),
),
@@ -231,6 +242,53 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
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];
@@ -269,6 +327,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: player.isGrouped,
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
@@ -276,6 +335,17 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
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);
+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),
+102 -27
View File
@@ -1,5 +1,6 @@
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';
@@ -18,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,
),
],
),
),
),
),
@@ -198,6 +219,11 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
);
}
// 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(
@@ -205,6 +231,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,
@@ -213,6 +256,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: [
@@ -302,6 +348,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(
+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.3-beta+25
version: 2.6.1-beta+29
environment:
sdk: '>=3.0.0 <4.0.0'