Compare commits

...
50 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
18 changed files with 1084 additions and 114 deletions
+47 -2
View File
@@ -153,12 +153,27 @@ class CachedQueue extends Table {
IntColumn get position => integer()();
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue])
/// 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 => 2;
int get schemaVersion => 3;
@override
MigrationStrategy get migration {
@@ -173,6 +188,10 @@ class AppDatabase extends _$AppDatabase {
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);
}
},
);
}
@@ -524,4 +543,30 @@ class AppDatabase extends _$AppDatabase {
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();
}
}
+6 -1
View File
@@ -281,5 +281,10 @@
"playerStateIdle": "Inaktiv",
"playerSelected": "Ausgewählt",
"actionQueuedForSync": "Wird synchronisiert wenn online",
"pendingOfflineActions": "{count} ausstehende Synchronisierung"
"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"
}
+51 -1
View File
@@ -1078,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"
},
@@ -1211,5 +1211,55 @@
"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"
}
}
+7 -2
View File
@@ -434,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": {
@@ -477,5 +477,10 @@
"playerStateIdle": "Inactivo",
"playerSelected": "Seleccionado",
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
"pendingOfflineActions": "{count} sincronización pendiente"
"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"
}
+54 -5
View File
@@ -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(
+184 -6
View File
@@ -51,6 +51,7 @@ class MusicAssistantProvider with ChangeNotifier {
List<Artist> _artists = [];
List<Album> _albums = [];
List<Track> _tracks = [];
List<Track> _cachedFavoriteTracks = []; // Cached for instant display before full library loads
bool _isLoading = false;
// Player state
@@ -221,6 +222,15 @@ class MusicAssistantProvider with ChangeNotifier {
// Load cached players from database for instant display (before connecting)
await _loadPlayersFromDatabase();
// Load cached library from SyncService for instant favorites display
await _loadLibraryFromCache();
// Load cached favorite tracks from database (tracks aren't in SyncService)
await _loadFavoriteTracksFromDatabase();
// Load cached home rows from database for instant discover/recent display
await _cacheService.loadHomeRowsFromDatabase();
// Initialize offline action queue
await OfflineActionQueue.instance.initialize();
@@ -272,12 +282,122 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('📦 Loaded ${players.length} players from database (instant)');
notifyListeners();
// Also load saved playback state for instant track display
if (_selectedPlayer != null) {
await _loadPlaybackStateFromDatabase();
}
}
} catch (e) {
_logger.log('⚠️ Error loading players from database: $e');
}
}
/// Load saved playback state (currentTrack) from database for instant display
Future<void> _loadPlaybackStateFromDatabase() async {
try {
final playbackState = await DatabaseService.instance.getPlaybackState();
if (playbackState == null) {
_logger.log('📦 No saved playback state in database');
return;
}
// Only restore if it matches the current player
if (playbackState.playerId != _selectedPlayer?.playerId) {
_logger.log('📦 Saved playback state is for different player, skipping');
return;
}
if (playbackState.currentTrackJson != null) {
try {
final trackData = jsonDecode(playbackState.currentTrackJson!) as Map<String, dynamic>;
_currentTrack = Track.fromJson(trackData);
_logger.log('📦 Restored currentTrack from database: ${_currentTrack?.name}');
notifyListeners();
} catch (e) {
_logger.log('⚠️ Error parsing cached track: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading playback state: $e');
}
}
/// Load library data from SyncService cache for instant favorites display
Future<void> _loadLibraryFromCache() async {
try {
final syncService = SyncService.instance;
// Ensure SyncService has loaded from database
if (!syncService.hasCache) {
await syncService.loadFromCache();
}
if (syncService.hasCache) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
// Note: tracks are loaded separately via API, not cached in SyncService
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
notifyListeners();
}
} catch (e) {
_logger.log('⚠️ Error loading library from cache: $e');
}
}
/// Persist current playback state to database (fire-and-forget)
void _persistPlaybackState() {
if (_selectedPlayer == null) return;
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
await DatabaseService.instance.savePlaybackState(
playerId: _selectedPlayer!.playerId,
playerName: _selectedPlayer!.name,
currentTrackJson: _currentTrack != null ? jsonEncode(_currentTrack!.toJson()) : null,
isPlaying: _selectedPlayer!.state == 'playing',
);
_logger.log('💾 Persisted playback state to database');
} catch (e) {
_logger.log('⚠️ Error persisting playback state: $e');
}
}();
}
/// Persist favorite tracks to database (fire-and-forget)
void _persistFavoriteTracks(List<Track> tracks) {
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
final tracksJson = jsonEncode(tracks.map((t) => t.toJson()).toList());
await DatabaseService.instance.saveHomeRowCache('favorite_tracks', tracksJson);
_logger.log('💾 Persisted ${tracks.length} favorite tracks to database');
} catch (e) {
_logger.log('⚠️ Error persisting favorite tracks: $e');
}
}();
}
/// Load cached favorite tracks from database
Future<void> _loadFavoriteTracksFromDatabase() async {
try {
if (!DatabaseService.instance.isInitialized) return;
final data = await DatabaseService.instance.getHomeRowCache('favorite_tracks');
if (data == null) return;
final items = (jsonDecode(data.itemsJson) as List)
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
_cachedFavoriteTracks = items;
_logger.log('📦 Loaded ${items.length} cached favorite tracks from database');
} catch (e) {
_logger.log('⚠️ Error loading favorite tracks: $e');
}
}
/// Persist players to database for app restart persistence
void _persistPlayersToDatabase(List<Player> players) {
// Run async but don't await - this is fire-and-forget persistence
@@ -1758,8 +1878,18 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get favorite tracks from the library
Future<List<Track>> getFavoriteTracks() async {
// Filter from loaded library - favorites are already loaded
return _tracks.where((t) => t.favorite == true).toList();
// If full tracks list is loaded, filter from it
if (_tracks.isNotEmpty) {
final favTracks = _tracks.where((t) => t.favorite == true).toList();
// Update cache if we have new data
if (favTracks.isNotEmpty && favTracks.length != _cachedFavoriteTracks.length) {
_cachedFavoriteTracks = favTracks;
_persistFavoriteTracks(favTracks);
}
return favTracks;
}
// Otherwise return cached favorites (for instant display before full library loads)
return _cachedFavoriteTracks;
}
// ============================================================================
@@ -2057,8 +2187,9 @@ class MusicAssistantProvider with ChangeNotifier {
.where((p) => p.name.endsWith(sendspinSuffix))
.toList();
// Clear the Cast-to-Sendspin ID mapping
_castToSendspinIdMap = {};
// NOTE: Don't clear _castToSendspinIdMap - we want to remember mappings
// even when the Sendspin player is temporarily unavailable (e.g., device off)
// This allows syncing to work when the device powers back on
if (sendspinPlayers.isNotEmpty) {
// Build maps for Sendspin players and their grouped status
@@ -2728,6 +2859,8 @@ class MusicAssistantProvider with ChangeNotifier {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
// Persist cleared track state
_persistPlaybackState();
}
// Clear audiobook context when playback stops
if (_currentAudiobook != null) {
@@ -2798,6 +2931,9 @@ class MusicAssistantProvider with ChangeNotifier {
}
stateChanged = true;
// Persist the updated playback state to database for instant restore on next launch
_persistPlaybackState();
// Clear audiobook context if switched to a different media item
// Check if the new track is from the same audiobook (by comparing URIs)
if (_currentAudiobook != null) {
@@ -2868,6 +3004,8 @@ class MusicAssistantProvider with ChangeNotifier {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
// Persist cleared track state
_persistPlaybackState();
}
// Show player name placeholder so notification shows correct player
@@ -3476,6 +3614,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
/// Toggle sync state: if player is synced, unsync it; otherwise sync to selected
/// For Cast/Sendspin players that are powered off, powers on first then syncs
Future<void> togglePlayerSync(String playerId) async {
_logger.log('🔗 togglePlayerSync called for: $playerId');
@@ -3487,13 +3626,52 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('🔗 Player found: ${player.name}, isGrouped: ${player.isGrouped}');
_logger.log('🔗 groupMembers: ${player.groupMembers}, syncedTo: ${player.syncedTo}');
_logger.log('🔗 powered: ${player.powered}, available: ${player.available}');
if (player.isGrouped) {
_logger.log('🔓 Player is grouped, unsyncing...');
await unsyncPlayer(playerId);
} else {
_logger.log('🔗 Player not grouped, syncing to selected...');
await syncPlayerToSelected(playerId);
// Check if this is a Cast player with a Sendspin counterpart that's powered off
final hasSendspinCounterpart = _castToSendspinIdMap.containsKey(playerId);
if (hasSendspinCounterpart && !player.powered) {
_logger.log('🔌 Cast/Sendspin player is off, powering on first...');
// Power on the Cast player
await _api?.setPower(playerId, true);
// Wait for the player to power on and Sendspin to become ready
_logger.log('🔗 Waiting for player to power on...');
// Poll for up to 10 seconds for the player to be powered and available
const maxAttempts = 20;
const pollInterval = Duration(milliseconds: 500);
for (var attempt = 0; attempt < maxAttempts; attempt++) {
await Future.delayed(pollInterval);
await refreshPlayers();
// Check if the Cast player is now powered on
final updatedPlayer = _availablePlayers.where(
(p) => p.playerId == playerId
).firstOrNull;
if (updatedPlayer != null && updatedPlayer.powered && updatedPlayer.available) {
_logger.log('✅ Player powered on, syncing...');
await syncPlayerToSelected(playerId);
return;
}
_logger.log('⏳ Attempt ${attempt + 1}/$maxAttempts - waiting for power on...');
}
_logger.log('⚠️ Timeout waiting for power on, attempting sync anyway...');
await syncPlayerToSelected(playerId);
} else {
_logger.log('🔗 Player not grouped, syncing to selected...');
await syncPlayerToSelected(playerId);
}
}
} catch (e) {
_logger.log('❌ togglePlayerSync error: $e');
+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
+46
View File
@@ -41,6 +41,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
// Hint settings
bool _showHints = true;
@override
void initState() {
@@ -89,6 +91,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
// Load hint settings
final showHints = await SettingsService.getShowHints();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -105,6 +110,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_libraryEnabled = libraryEnabled;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_showHints = showHints;
});
}
}
@@ -699,6 +705,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// Hints & Tips section
Text(
S.of(context)!.hintsAndTips,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.showHintsDescription,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.showHints,
style: TextStyle(color: colorScheme.onSurface),
),
value: _showHints,
onChanged: (value) {
setState(() => _showHints = value);
SettingsService.setShowHints(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Home Screen section
Text(
S.of(context)!.homeScreen,
+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');
}
}();
}
}
+17
View File
@@ -222,4 +222,21 @@ class DatabaseService {
/// 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();
}
+43
View File
@@ -74,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);
@@ -762,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);
}
}
+49 -8
View File
@@ -36,12 +36,17 @@ class ExpandablePlayer extends StatefulWidget {
/// 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
@@ -1483,11 +1488,48 @@ 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,
@@ -1514,24 +1556,23 @@ 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(
// Show "Now Playing" when device reveal visible and collapsed
(widget.isDeviceRevealVisible && t < 0.5)
? S.of(context)!.nowPlaying
: maProvider.isPlayingAudiobook
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
: currentTrack.artistsString,
// 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 : MiniPlayerLayout.secondaryTextOpacity),
fontSize: artistFontSize,
+365 -54
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -5,6 +6,7 @@ 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';
@@ -130,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();
}
@@ -145,17 +147,40 @@ 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;
// Use ValueNotifier instead of setState for bounce offset
// This prevents full widget tree rebuilds during animation
// 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>();
@@ -172,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,
);
// Use ValueNotifier instead of setState to avoid full widget rebuilds
// This isolates the rebuild to only the widgets that depend on bounce offset
_bounceController.addListener(() {
final t = _bounceAnimation.value;
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
_bounceOffsetNotifier.value = 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();
}
@@ -213,11 +342,29 @@ 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() {
@@ -225,6 +372,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
_bounceOffsetNotifier.value = 0;
setState(() {
_isRevealVisible = false;
_isOnboardingReveal = false;
});
}
@@ -232,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
@@ -247,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(
@@ -264,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
@@ -360,22 +559,125 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
);
},
);
},
},
),
),
),
// Blur backdrop when device selector is open
if (_isRevealVisible)
// Blur backdrop for device selector (reveal mode) - static, no animation
if (_isRevealVisible && !_isHintModeActive)
Positioned.fill(
child: IgnorePointer(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _dismissPlayerReveal,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
color: Colors.black.withOpacity(0.1),
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(
@@ -383,34 +685,43 @@ 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();
}
// Combine slide and bounce animations with ValueListenableBuilder
// 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 AnimatedBuilder(
animation: _slideAnimation,
builder: (context, _) {
return ExpandablePlayer(
key: globalPlayerKey,
slideOffset: _slideAnimation.value,
bounceOffset: bounceOffset,
onRevealPlayers: _showPlayerReveal,
isDeviceRevealVisible: _isRevealVisible,
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,
);
},
);
},
);
@@ -67,6 +67,7 @@ class DeviceSelectorBar extends StatelessWidget {
textColor: textColor,
width: width,
slideOffset: slideOffset,
isHint: swipeHint != null,
),
],
),
@@ -97,6 +98,7 @@ class DeviceSelectorBar extends StatelessWidget {
textColor: textColor,
width: width,
slideOffset: 0, // No additional slide - Transform handles positioning
isHint: swipeHint != null,
),
);
}
+37 -10
View File
@@ -48,6 +48,9 @@ class MiniPlayerContent extends StatelessWidget {
/// 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;
@@ -64,6 +67,7 @@ class MiniPlayerContent extends StatelessWidget {
required this.textColor,
required this.width,
this.slideOffset = 0.0,
this.isHint = false,
this.showProgress = false,
this.progress = 0.0,
});
@@ -136,21 +140,44 @@ class MiniPlayerContent extends StatelessWidget {
),
),
// Secondary text line
// Secondary text line (with optional hint icon)
if (hasSecondaryLine)
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: MiniPlayerLayout.secondaryTop,
right: MiniPlayerLayout.textRightPadding - slidePixels,
child: Text(
secondaryText!,
style: TextStyle(
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
fontSize: MiniPlayerLayout.secondaryFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
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,
),
),
],
),
+65 -7
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];
@@ -277,7 +335,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
maProvider.selectPlayer(player);
dismiss();
},
onLongPress: player.available ? () {
onLongPress: () {
HapticFeedback.mediumImpact();
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
ScaffoldMessenger.of(context).showSnackBar(
@@ -287,7 +345,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
),
);
maProvider.togglePlayerSync(player.playerId);
} : null,
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
+15 -17
View File
@@ -231,25 +231,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
maProvider.selectPlayer(player);
Navigator.pop(context);
},
onLongPress: player.available
? () {
// Haptic feedback for sync action
HapticFeedback.mediumImpact();
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),
),
);
// 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);
}
: null,
// 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,
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.5.0-beta+27
version: 2.6.1-beta+29
environment:
sdk: '>=3.0.0 <4.0.0'