Compare commits

..
171 Commits
Author SHA1 Message Date
Your NameandClaude Opus 4.5 eedf846b36 Bump version to 2.7.4-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 23:19:33 +00:00
Your NameandClaude Opus 4.5 a2788d6815 Improve accessibility and touch targets
Accessibility:
- Add Semantics to player control buttons (previous, play/pause, next)
- Include semantic labels and hints for screen readers
- Play/pause button includes "long press to stop" hint

Touch Targets:
- Increase 28dp touch targets to 44dp in player_card.dart
- Increase 28dp touch targets to 44dp in expandable_player.dart
- Comments note icons remain 28dp for visual balance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:58:12 +00:00
Your NameandClaude Opus 4.5 7cbe8cf15b Fix memory leaks, race conditions, and stability issues
Memory Management:
- Add LRU eviction to all caches with configurable max sizes
- Fix static memory leak in Player model with proper LRU cleanup
- Add PCM buffer overflow protection (max 500 chunks / ~10s)

Database Performance:
- Add indexes for RecentlyPlayed (profile, played_at)
- Add indexes for LibraryCache (itemType, isDeleted)
- Add indexes for CachedQueue (playerId, position)
- Bump schema version to 5 with migration

Race Conditions:
- Add connection deduplication guard in SendspinService
- Complete pending API requests on disconnect instead of abandoning
- Add position tracker anchor timeout (30s max drift)

Error Handling:
- Add catchError to fire-and-forget pause/skip commands
- Log unawaited errors instead of silently dropping them

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:55:59 +00:00
Your NameandClaude Opus 4.5 79e3862895 Fix critical audit issues: secure storage, memory leaks, race conditions
Security:
- Add flutter_secure_storage dependency for credential protection
- Create SecureStorageService for encrypted credential storage
- Migrate auth tokens, passwords, API tokens from SharedPreferences
- Add automatic migration for existing users in main.dart

Memory Leaks:
- Fix audio session listener disposal in MassivAudioHandler
- Store all 4 stream subscriptions and add dispose() method
- Store connection state subscription in MusicAssistantProvider
- Fix StreamController disposal in HardwareVolumeService

Race Conditions:
- Add reentrancy guard to selectPlayer() to prevent concurrent calls

Code Quality:
- Fix 7 silent exception handlers across codebase
- Log errors with context instead of swallowing silently
- Fix double opacity bug in player_controls.dart shuffle/repeat buttons

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:28:49 +00:00
Your NameandClaude Opus 4.5 02d5dac065 Add comprehensive codebase audit report
Complete audit covering 6 areas:
- Security: 6 CRITICAL issues (plaintext credentials, cert validation)
- Memory: 6 leak locations (unbounded caches, untracked subscriptions)
- Race Conditions: 4 issues (player state, async reentrancy)
- Architecture: God class (4,469 LOC), zero tests, 55 hardcoded colors
- Accessibility: 0 Semantics widgets, touch targets below 48dp
- Audio/Networking: 6 HIGH issues (no indexes, non-atomic queue)

Overall score: 5.5/10
Estimated technical debt: 44-66 dev-days

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:16:36 +00:00
Your NameandClaude Opus 4.5 f000806e18 Add volume swipe hint and shorten sync hint
Device selector now shows two hints:
- "Long-press to sync" (shortened from "Tap and hold a player to sync")
- "Swipe to adjust volume" (new)

Updated all three language files (en, de, es).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:24:46 +00:00
Your NameandClaude Opus 4.5 7c18eb1b71 Fix sync: translate both target AND leader to Sendspin IDs
Cast players (chromecast provider) don't support group commands - only
their Sendspin counterparts do. The previous fix incorrectly tried to
use Cast UUIDs as leaders, which caused "Player X does not support
group commands" errors.

Now we translate BOTH the target AND leader to Sendspin IDs when syncing,
since only Sendspin players support the SET_MEMBERS feature required for
grouping.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:55:57 +00:00
Your NameandClaude Opus 4.5 23292e796c Remove verbose isPlayerManuallySynced debug logging
This debug log was firing on every UI build cycle for every player card,
creating excessive noise in the logs.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:37:41 +00:00
Your NameandClaude Opus 4.5 c0e74c0539 Fix unsync for pure Sendspin players that don't support set_members
Pure Sendspin CLI players (like sendspin-cli-dining-room-hifi) don't
implement the SET_MEMBERS feature required for ungrouping. When unsyncing
such a player fails with "set_members needs to be implemented", fall back
to unsyncing via the leader instead. This dissolves the entire group but
at least provides a working path.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:36:34 +00:00
Your NameandClaude Opus 4.5 c965ed2c78 Workaround: Use Cast UUID as leader when syncing players
When syncing a player to the leader, use the Cast player UUID instead of
the Sendspin ID. This may help prevent MA from switching to the Sendspin
player's queue (which can have stale content like old audiobooks).

The root cause is a Music Assistant server bug where the Sendspin player
has a separate queue from the Cast player, and syncing with the Sendspin
ID as leader causes MA to start streaming from the Sendspin queue.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:25:32 +00:00
Your NameandClaude Opus 4.5 c060bcffdf Fix unsync removing leader instead of child from sync group
When long-pressing a child player to unsync it, the code was incorrectly
calling unsync on the leader instead, which caused the leader to leave
the group while the child remained synced to another player.

The flawed logic tried to "unsync via leader first" for child players,
but calling ungroup on a leader dissolves the entire group rather than
removing a specific child.

Now unsyncs the child directly, which correctly removes just that player
from the sync group.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:07:15 +00:00
Your NameandClaude Opus 4.5 8604e5ee12 Remove Tracks heading and standardize Play On menus
- Remove redundant "Tracks" heading from album detail view
- Update album_details_screen to use shared showPlayerPickerSheet
- Update audiobook_detail_screen to use shared showPlayerPickerSheet
- All Play On menus now have status indicators, drag handle, and theming

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:32:43 +00:00
Your NameandClaude Opus 4.5 d237e07d95 Fix mini player layout for 2-line vs 3-line states
- Add centered 2-line layout for non-playing state (player off)
- Add tertiaryText parameter to MiniPlayerContent for 3-line support
- Peek content now shows 3 lines when player has track, 2 centered when not
- DeviceSelectorBar uses centered 2-line layout (no empty third line)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:55:33 +00:00
Your NameandClaude Opus 4.5 b91789a6f0 Clarify directional volume swipe behavior (Option 4)
Volume swipe already uses incremental deltas - drag right increases,
drag left decreases. Multiple swipes accumulate as each starts from
current volume. Removed unused _dragStartX variable and added comments
to clarify this behavior.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:21:22 +00:00
Your NameandClaude Opus 4.5 28fc83613e Fix mini player volume overlay z-order
Move volume overlay to end of Stack children so it renders on top of
all content (album art, track info, buttons) when adjusting volume.
Matches PlayerCard behavior where overlay completely covers the card.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:08:51 +00:00
Your NameandClaude Opus 4.5 12d0d90193 Throttle volume API calls to prevent flooding
- Only send volume updates every 150ms during drag
- Send final volume value on release
- Visual feedback still updates immediately

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:46:52 +00:00
Your NameandClaude Opus 4.5 1980fbb37f Add volume swipe to mini player when device list is expanded
When device reveal overlay is visible:
- Mini player horizontal swipe = volume control (two-tone overlay)
- Live volume changes as you drag

When device reveal is closed:
- Mini player horizontal swipe = change player (existing behavior)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:36:59 +00:00
Your NameandClaude Opus 4.5 406379e3e5 Refine mini player 3-line layout and add volume swipe to player cards
Mini player refinements:
- Reduce height from 76px to 72px
- Adjust line spacing (secondary at 27px, tertiary at 46px)
- Remove player name swap on primary line when reveal visible
  (no longer needed since player name is now on line 3)

Volume swipe on PlayerCards:
- Horizontal swipe adjusts volume with live feedback
- Two-tone overlay covers card while dragging (same colors as progress bar)
- Volume changes apply immediately as you drag

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 17:21:18 +00:00
Your NameandClaude Opus 4.5 7bb514ac74 Fix const error: BottomSpacing.withMiniPlayer is now a getter
Remove const from SliverToBoxAdapter that uses the non-const getter.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:59:10 +00:00
Your NameandClaude Opus 4.5 ab0f037ecf Add 3-line mini player: track, artist, player name
- Increase mini player height from 64px to 76px
- Increase album art to 76x76 to fill vertical space
- Add player name as third line (same size as artist, same color as track)
- Refactor BottomSpacing to reference MiniPlayerLayout.height
- Refactor _collapsedHeight and _collapsedArtSize to reference MiniPlayerLayout

Single source of truth for mini player dimensions now.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:46:54 +00:00
Your NameandClaude Opus 4.5 75167b58a9 Merge feature/player-name-pill: Sendspin sync fixes
Key changes:
- Cast-to-Sendspin ID mapping and persistence
- Sync fixes for unavailable/mixed player types
- Manual sync yellow border improvements
- Player name pill removed (didn't work out)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:13:37 +00:00
Your NameandClaude Opus 4.5 28bd1ad125 Remove player name pill from mini player
The pill experiment didn't work out - removing it entirely.
Keeps all the Sendspin sync fixes.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:12:34 +00:00
Your NameandClaude Opus 4.5 cea6dd46e3 Persist Cast-to-Sendspin mappings in database
When we discover a Cast+Sendspin player pair, save the mapping to
the database. On startup, load persisted mappings into memory.
This allows syncing to work even when the Sendspin player is
unavailable (device off) - we remember that it has Sendspin enabled.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 12:06:57 +00:00
Your NameandClaude Opus 4.5 3935312982 Fix sync for unavailable Sendspin players
When a Cast device with Sendspin is powered off, its Sendspin player
becomes unavailable and won't be in _castToSendspinIdMap. Now we also
check if the computed Sendspin ID exists in any sync group's members
(like "All Speakers") to identify players with Sendspin enabled.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:58:24 +00:00
Your NameandClaude Opus 4.5 634d5ddac2 Fix sync for Cast players without Sendspin
Only translate Cast IDs to Sendspin IDs when the player is
actually in _castToSendspinIdMap. Cast players without Sendspin
enabled should use their original UUID for group commands.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:43:08 +00:00
Your NameandClaude Opus 4.5 26282a1945 Fix unsync for child players that don't support set_members
When unsyncing a child player (like Sendspin CLI), the direct unsync call
fails with "set_members needs to be implemented" because the player doesn't
support that feature.

Fix: When unsyncing a child player, first try to unsync via the leader
(which does support set_members). This translates the syncedTo Cast UUID
to the Sendspin leader ID and calls unsync on that instead.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:37:59 +00:00
Your NameandClaude Opus 4.5 111c74ce1a Fix queue fetch for Sendspin players - translate ID to Cast UUID
When fetching the queue for a Sendspin player (e.g., cast-7df484e3),
we need to translate it to the corresponding Cast UUID (e.g.,
7df484e3-d2ee-c897-f746-2dffc29595ff) because MA stores queues under
the Cast UUID, not the Sendspin ID.

This was causing the mini player to show "off state" for Shield after
syncing another player to it - the queue fetch was returning empty
because we were querying with the wrong player ID.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:23:45 +00:00
Your NameandClaude Opus 4.5 39c462d624 Fix Sendspin player track cache lookup for mini player
When a Sendspin player (e.g., Shield) is selected and synced to by another
player, the mini player was showing "off state" because track cache lookups
were failing. The issue was that track events come from MA with Cast UUIDs
but the selected player uses Sendspin IDs (cast-xxxxxxxx format).

Changes:
- Add getAllCachedPlayerIds() to cache service for dynamic ID lookup
- Add dual-caching: when caching a track for a Cast player, also cache
  under its computed Sendspin ID (cast-{first 8 chars of UUID})
- This applies to _handlePlayerUpdatedEvent, _preloadPlayerTrack, and
  _updatePlayerState where tracks are cached
- Add dynamic Sendspin ID computation when looking up tracks, trying
  both the map and computed IDs as fallbacks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:01:26 +00:00
Your NameandClaude Opus 4.5 fd4d264850 Adjust pill: move right 1px, use dark tertiary color
- Changed right: 1 to right: 0 (moves pill right 1px)
- Use tertiaryContainer for background (darker complementary color)
- Use onTertiaryContainer for text color

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:45:46 +00:00
Your NameandClaude Opus 4.5 26accb81e8 Fix Sendspin player state and name in mini player
1. Track cache lookup now checks translated Cast<->Sendspin IDs
2. Player state updates preserve the renamed name (without "(Sendspin)")
   by using processed player from _availablePlayers
3. Player update events now trigger state refresh for translated IDs
   (events may come with Cast ID but selected player is Sendspin)

This fixes:
- Mini player showing "Shield (Sendspin)" instead of "Shield"
- Mini player showing off state when Shield is selected and synced
- Track info not loading for Sendspin players

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:38:51 +00:00
Your NameandClaude Opus 4.5 0e8ab387e2 Fix sync UI for Cast+Sendspin players
1. Yellow border now shows on synced children even when leader is
   Sendspin version (translated ID lookup in isPlayerManuallySynced)
2. Selected player no longer jumps after sync when Cast player gets
   replaced by Sendspin version (ID translation in player selection)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:14:32 +00:00
Your NameandClaude Opus 4.5 3073080c77 Make player name pill match mini player colors
Use primaryContainer/onPrimaryContainer instead of tertiary colors
for a more subtle, unified appearance with the mini player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:08:20 +00:00
Your NameandClaude Opus 4.5 a63bd5a3e0 Fix sync TO Cast players by computing Sendspin ID dynamically
The previous fix only worked when the Sendspin player was already
visible in the player list. But Cast players need their Sendspin
counterpart for group commands, even before Sendspin playback starts.

This fix:
- Computes Sendspin ID dynamically for chromecast players: cast-{first 8 chars of UUID}
- No longer relies on _castToSendspinIdMap having a pre-built mapping
- Works for both Kitchen speaker and Shield (any Cast+Sendspin device)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:56:54 +00:00
Your NameandClaude Opus 4.5 aa366a0910 Fix Cast+Sendspin sync for devices with raw ID naming
Some Cast devices (like Shield) have their Sendspin player registered
with a raw ID as the name (e.g., "cast-7df484e3") instead of the
standard "DeviceName (Sendspin)" format.

This fix:
- Detects Sendspin players by ID pattern (cast-{uuid-prefix})
- Maps Cast player IDs to Sendspin IDs for these raw-named players
- Enables sync TO these Cast devices by translating the leader ID

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:47:49 +00:00
Your NameandClaude Opus 4.5 d8edf19699 Refine player name pill positioning and border radius
- Move pill 1px left to hide sharp corner behind mini player curve
- Match pill border radius to mini player (16px) for seamless look
- Increase left padding to accommodate larger radius
- Raise text up (top padding 5→3) for better vertical balance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:22:19 +00:00
Your NameandClaude Opus 4.5 3d9e4b9a93 Fix sync to Cast+Sendspin players by translating leader ID
When syncing to a Chromecast player that has Sendspin enabled,
the leader player ID also needs to be translated from the Cast ID
to the Sendspin ID. Previously only the target was translated.

This fixes "Player Shield does not support group commands" error
when trying to sync other players to a Cast+Sendspin player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:16:50 +00:00
Your NameandClaude Opus 4.5 0c1995d918 Fix sync to Cast+Sendspin players by translating leader ID
When syncing to a Chromecast player that has Sendspin enabled,
the leader player ID also needs to be translated from the Cast ID
to the Sendspin ID. Previously only the target was translated.

This fixes "Player Shield does not support group commands" error
when trying to sync other players to a Cast+Sendspin player.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:10:16 +00:00
Your NameandClaude Opus 4.5 e1fb75b868 Player name pill with teardrop tail effect behind mini player
- Pill renders BEHIND mini player (first in Stack)
- Tertiary color for contrast
- Extends down behind player with bottom padding
- Mini player's curved corner reveals the "tail" naturally tapering
- Top corners rounded, bottom straight for seamless extension

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:50:08 +00:00
Your NameandClaude Opus 4.5 f56b4e6b24 Integrate player name pill as seamless tab with mini player
- Top right position, flush with right edge
- Same background color as mini player (collapsedBgUnplayed)
- Progress bar flows through the pill matching main player
- Only top-left corner rounded, right side matches player border radius
- Creates seamless integrated appearance

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 00:21:52 +00:00
Your NameandClaude Opus 4.5 5146035a6d Move player name pill to top left of mini player
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:43:12 +00:00
Your NameandClaude Opus 4.5 4aca263bf2 Move player name pill to top right of mini player
- Position at top right instead of centered below
- Right edge flush with mini player edge
- Slightly larger (font 12, padding 12x5)
- Reduced border radius (8) for smoother transition

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:26:09 +00:00
Your NameandClaude Opus 4.5 8725a4a0b2 Fix version code by using manual build number (fixes #34)
- Reverted workflow to not use --build-number flag
- Added +35 build number to pubspec.yaml (higher than previous 31)
- Build numbers should be manually incremented in pubspec.yaml for each release

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 19:30:47 +00:00
Your NameandClaude Opus 4.5 b144ff48fb Fix version code always being 1 (fixes #34)
Use GitHub run_number as the build number to ensure each build has an
incrementing versionCode. This fixes the "newer version already installed"
error when upgrading.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 19:25:31 +00:00
Your NameandClaude Opus 4.5 524289924b Move player name pill to center position
Horizontally centered below the mini player instead of bottom-right.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 19:12:29 +00:00
Your NameandClaude Opus 4.5 6bf358cc0e Fix pill font to use Roboto explicitly
The player name pill was inheriting some weird text style from parent widgets.
Added explicit fontFamily: 'Roboto' to match the app's design.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 18:55:38 +00:00
Your NameandClaude Opus 4.5 e269132b22 fix: Move pill outside Material to prevent clipping
Wrap Container in outer Stack with Clip.none, place pill in outer Stack
so it's not clipped by Material's Clip.antiAlias

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 18:30:58 +00:00
Your NameandClaude Opus 4.5 7453aa548a fix: Move pill inside inner Stack for correct z-order, fix font styling
- Move pill to end of Stack children so it renders on top
- Add explicit TextStyle properties to prevent inherited styling:
  - decoration: TextDecoration.none (removes underline)
  - fontFamily: null (uses default font, not monospace)
  - letterSpacing: 0
- Slightly larger: 11px font, 12px radius, more padding

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:16:09 +00:00
Your NameandClaude Opus 4.5 073ab14d2b feat: Add player name pill attached to bottom-right of mini player
- Pill shows selected player name below the mini player
- Uses adaptive tertiary color for complementary look
- Fades out smoothly during player expansion
- Only visible when track is playing (DeviceSelectorBar already shows player name when idle)
- Positioned at bottom: -10px, right: 8px to attach beneath mini player

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:59:44 +00:00
Your NameandClaude Opus 4.5 22fc4dd42a fix: Distinguish pre-configured groups from manual sync by checking if leader ID is in groupMembers
Pre-configured group players (like "All Speakers") have groupMembers that
list the child players, but the group's own ID is NOT in that list.
Manual sync groups have the leader's ID IN the groupMembers list.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:06:21 +00:00
Your NameandClaude Opus 4.5 fd7bcc67cf debug: Use DebugLogger for yellow border diagnostics
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:03:57 +00:00
Your NameandClaude Opus 4.5 3ffdb071b6 debug: Add logging to diagnose group player yellow border issue
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 10:37:39 +00:00
Your NameandClaude Opus 4.5 cce8cf6f63 fix: Group players never show yellow border + tap to dismiss when player off
- Move player_group check to top of isPlayerManuallySynced() to prevent
  edge cases where group players could incorrectly show yellow border
- Add onTap handler to _buildDeviceSelectorBar to dismiss player reveal
  when tapping selected player that has no track (matching morphing player)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 10:09:11 +00:00
Your NameandClaude Opus 4.5 bb2f33229e fix: Yellow border shows for both leader and children in manual sync
Updated isPlayerManuallySynced to detect:
- Case 1: Player is a child synced to a regular player (not a group)
- Case 2: Player is a leader with groupMembers (but not a player_group provider)

This ensures both players in a manual sync show the yellow border,
while pre-configured groups like "All Speakers" show no border.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 09:32:22 +00:00
Your NameandClaude Opus 4.5 ed90459049 fix: Use isPlayerManuallySynced for player reveal overlay cards
The second PlayerCard in player_reveal_overlay.dart was using
player.isGrouped directly instead of the provider's isPlayerManuallySynced
method. This caused group players like "All Speakers" to incorrectly show
the yellow synced border. Now both PlayerCard instances use the same
correct logic that only highlights manually synced players.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 09:08:57 +00:00
Your NameandClaude Opus 4.5 77531e6d62 fix: Yellow border only for manually synced players, not MA groups
- Add isPlayerManuallySynced() helper in provider to check if syncedTo target is a regular player
- Pre-configured MA speaker groups (provider='player_group') no longer show yellow border
- Fix PlayerCard border clipping by using Stack with border overlay on top
- Update all widgets to use provider method for sync detection

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 06:13:09 +00:00
Your NameandClaude Opus 4.5 8122242045 fix: Improve player list UX and synced player highlighting
- Tapping active player when list expanded now collapses the list
- Scribe player shows Play/Next/Power buttons when list visible (matching other players)
- Yellow synced border renders on top using foregroundDecoration (fixes corner clipping)
- Yellow border only shows for manually synced players, not pre-configured MA groups

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 05:54:43 +00:00
Your NameandClaude Opus 4.5 7f871f0078 chore: release v2.7.3-beta
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:40:57 +00:00
Your NameandClaude Opus 4.5 573a5f6fe7 chore: bump to 2.7.3-beta+36 with shuffle queue fix
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:35:44 +00:00
Your NameandClaude Opus 4.5 cb87492c9c fix: shuffle toggle now correctly updates queue display
Root cause: After toggling shuffle, the queue comparison only checked
metadata (length, currentIndex, shuffle state). When the optimistic
update set shuffleEnabled=true with old items, and the server returned
shuffleEnabled=true with reordered items, the comparison showed "no change"
because both had the same shuffle state.

Changes:
- Add item reordering detection by comparing first 3 queue item IDs
- Add 150ms delay after toggle API call to ensure server finishes reordering
- Add debug logging to track reordering detection

Fixes queue display not updating when shuffle is toggled on/off.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 07:35:24 +00:00
Your Name b1b91e8dca chore: bump to 2.7.3-beta+35 with shuffle button visual fix 2025-12-30 07:14:38 +00:00
Your NameandClaude Opus 4.5 ba0f21856a fix: shuffle/repeat button visual state now updates immediately
Apply optimistic UI updates for shuffle and repeat toggles:
- Immediately update local queue state before API call
- Prevents cached queue from overwriting optimistic state
- Ensures button color changes instantly on tap

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 06:57:04 +00:00
Your Name 218bec9607 debug: add more shuffle state logging 2025-12-30 06:27:50 +00:00
Your Name 2d491e1ae9 chore: bump to 2.7.3-beta+33 with shuffle logging 2025-12-30 06:01:07 +00:00
Your Name 1ab51bbf25 debug: add shuffle toggle logging for diagnostics 2025-12-30 05:57:56 +00:00
Your Name 5e6da3ec2f chore: bump version to 2.7.3-beta+32 2025-12-30 05:56:32 +00:00
Your NameandClaude Opus 4.5 3894574a05 fix(#28): add shuffle_enabled parameter to toggle shuffle API
fix(#21): prevent early return on coldStart for auto-selection priority

The shuffle button was failing because the Music Assistant API requires
the shuffle_enabled boolean parameter. Added the parameter through all
layers (API → provider → widget).

The auto-selection wasn't applying Priority 2 (local player) when
"Prefer Local Player" was disabled because an early cache-valid return
was bypassing the cold start priority logic.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 05:37:30 +00:00
Your NameandClaude Opus 4.5 7fe4e553ee feat: add app_id logging for external source diagnosis
Expose app_id on Player model and log it for playing players
to help diagnose why Spotify Connect isn't detected as external source.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 05:26:27 +00:00
Your NameandClaude Opus 4.5 459eb316b2 fix: correct method name clearRemotePlaybackState
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:25:30 +00:00
Your NameandClaude Opus 4.5 d3d03fd4d1 fix(#27): clear stale track data when external source takes over
When a player is using an external source (Spotify Connect, TV optical,
etc.), Ensemble was showing stale MA track data instead of clearing it.

Changes:
- selectPlayer(): Clear _currentTrack for external source players
- selectPlayer(): Skip position tracker updates for external sources
- _preloadPlayerTrack(): Skip preloading for external source players
- _updatePlayerState(): Early return for external sources, clearing
  track, audiobook context, and notification
- player_selector.dart: Exclude external sources from badge count
- Auto-selection logic: Exclude external sources from "playing player"
  priority in both music_assistant_provider and player_provider

The isExternalSource flag (detecting app_id != 'music_assistant') was
already being set correctly in the Player model, but wasn't being used
throughout the app to prevent showing stale data.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 23:16:35 +00:00
Your NameandClaude Opus 4.5 8f5008b902 fix: responsive cover sizing on detail screens
Use percentage-based sizing (40-50% of screen width) with min/max
clamps to adapt covers to different screen sizes (phones vs tablets).

Updated screens:
- album_details_screen: 50% width, 200-320px
- audiobook_detail_screen: 50% width, 200-320px
- audiobook_series_screen: 50% width, 200-320px
- artist_details_screen: 40% width, 160-280px (circular)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 22:32:41 +00:00
Your NameandClaude Opus 4.5 c3acd0029e fix: correct nullable return type in device ID service
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:36:55 +00:00
Your NameandClaude Opus 4.5 925feb89b6 fix: use stable device ID to prevent stale players
- Replace random UUID with ANDROID_ID (survives storage wipes)
- Include MA username in player ID for multi-device support
- Auto-detect Phone vs Tablet based on screen size (600dp threshold)
- Format: ensemble_{username}_{deviceId8}
- Prevents duplicate players when clearing app storage

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:26:29 +00:00
Your Name 29758eb9bb chore: bump version to 2.7.2-beta 2025-12-29 18:28:52 +00:00
Your NameandClaude Opus 4.5 332de9e00c perf: search screen audit fixes
- Only rebuild when clear button visibility changes (not every keystroke)
- Use context.select for connection status to avoid unnecessary rebuilds
- Cache available filters list to avoid recalculating per build
- Use theme-aware colors for filter chips (light/dark mode support)
- Remove dead code (_buildListItems method - 88 lines)
- Use safe .whereType<T>() instead of unsafe .cast<T>()

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:05:49 +00:00
Your Name 6f503c8540 fix: add gap between search bar and filter bar, optimize scroll to avoid animation jank 2025-12-29 17:41:45 +00:00
Your Name 9950ff2063 fix: remove container wrapper and extra padding from filter bar 2025-12-29 17:25:49 +00:00
Your Name 4c777a93e1 fix: add solid background to filter bar container 2025-12-29 17:15:54 +00:00
Your Name a96578bbf0 fix: remove gap between filter bar and results 2025-12-29 16:54:16 +00:00
Your Name 8fa95705e1 fix: keep filter bar visible when scrolling 2025-12-29 16:34:17 +00:00
Your NameandClaude Opus 4.5 9dcca2ce54 fix: improve search filter bar scroll and layout behavior
- Filter bar only scrolls when active filter would be off-screen
- Changed from Stack overlay to Column layout
- Results no longer scroll behind filter bar
- Filter bar hides on scroll along with search bar

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:17:02 +00:00
Your NameandClaude Opus 4.5 4152c3d67f perf(#23): use ListView.builder for player list with many players
- Only builds visible player cards (lazy loading)
- Uses itemExtent for optimal scroll performance
- Reduces animation work from O(n) to O(visible) per frame
- Should fix choppy animation reported by shpongledsummer

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 14:42:10 +00:00
Your NameandClaude Opus 4.5 d84fb58a5c fix: only allow swipe down for dismiss, ignore swipe up
Swipe up was incorrectly moving cards down because delta negation
was inverting the direction. Now:
- Swipe up (finger up): ignored when not scrollable
- Swipe down (finger down): triggers dismiss gesture

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:50:57 +00:00
Your NameandClaude Opus 4.5 128d417014 feat(#23): smart scroll for player list overflow
- Add scroll when player list exceeds 80% of available screen height
- Swipe down dismisses only when scrolled to top
- When scrolled down, swipe gestures scroll the list instead
- No behavior change for small player counts (5-6 players)

This matches the common pattern used by Instagram, Apple Maps, etc.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:35:49 +00:00
Your NameandClaude Opus 4.5 176fa5c8b9 fix: revert player list scrollability changes
The scrollability changes were interfering with the drag-to-dismiss
gesture. For normal player counts (5-6), scrolling isn't needed.
Issue #23 (many players overflow) needs a different approach.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:04:38 +00:00
Your NameandClaude Opus 4.5 ccaf764f52 Add missing player_picker_sheet.dart from feature branch
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:45:37 +00:00
Your NameandClaude Opus 4.5 ccda231fb2 Fix GitHub issues #27, #23, add hero animations to search
## Issue Fixes

### #27 - DLNA external source detection
- Add app_id check as primary indicator for external sources
- When app_id != 'music_assistant', mark as external source
- Keeps existing URI-based detection as fallback

### #23 - Speaker selection scrollability
- Add top constraint to player overlay for max height
- Wrap player list in SingleChildScrollView with Flexible
- Enable scrolling when more than 5 players (BouncingScrollPhysics)
- Prevents player cards from overflowing on devices with many speakers

### #21 - Auto-selection debugging
- Add detailed logging when Priority 2 (local player) fails
- Log whether local player is missing from list or not available
- Log when builtinPlayerId is null

## Enhancements

### Hero animations for search results
- Add hero animations to album, artist, and audiobook tiles
- Use FadeSlidePageRoute for smooth transitions
- Use 'search' suffix for hero tags to avoid conflicts
- Update adaptive colors on navigation tap

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:34:13 +00:00
Your NameandClaude Opus 4.5 0f82829172 Fix action button consistency on artist details screen
- Radio button now shows player selection popup (consistent with search/album)
- Add to Queue button now shows player selection popup (was broken - did nothing without selected player)
- Removed redundant "Play On" button (Radio now provides the same functionality)
- Renamed _showPlayOnMenu to _showRadioMenu for clarity
- Removed unused _startArtistRadio method

Artist details header now has: [Radio] [Add to Queue] [Favorite]
All buttons work consistently with popup selection, matching search/album behavior.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:53:16 +00:00
Your NameandClaude Opus 4.5 0ccde33c85 Standardize bottom sheet implementations across all screens
- All sheets now use consistent pattern: transparent background with
  Container decoration, mainAxisSize.min, Flexible with shrinkWrap
- All sheets hide mini player before showing and restore on dismiss
- All sheets include safe area bottom padding
- Fixed album_details_screen _showPlayOnSheet (was using fixed 45% height)
- Fixed artist_details_screen _showPlayOnMenu (was missing hidePlayer/showPlayer)
- Fixed audiobook_detail_screen _showPlayOnMenu (was using fixed 45% height)
- Added safe area padding to search_screen sheets

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:40:49 +00:00
Your NameandClaude Opus 4.5 3f9fb5d1e9 Bump version to 2.7.0-beta+30 for release
🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Add localization strings: albumSingular, trackSingular

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All new text strings are localized.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Both should show the yellow border now.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses feedback from issue #21

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:21:27 +00:00
40 changed files with 6136 additions and 985 deletions
+391
View File
@@ -0,0 +1,391 @@
# Ensemble Codebase Intensive Audit Prompt
## Overview
You are tasked with conducting an intensive, comprehensive audit of the **Ensemble** Flutter application - a Music Assistant client for Android with multi-device synchronization, local playback, and Sendspin raw audio streaming capabilities.
**Codebase Statistics:**
- 90 Dart files, ~40,583 lines of code
- 18 screens, 27 services, 6 providers
- Architecture: Provider pattern with ChangeNotifier
- Audio: just_audio + flutter_pcm_sound + audio_service
- Networking: WebSocket (JSON-RPC) + HTTP + Sendspin binary protocol
---
## Audit Scope
Perform a thorough audit covering ALL of the following areas. For each issue found, provide:
1. **File path and line number(s)**
2. **Severity**: CRITICAL / HIGH / MEDIUM / LOW
3. **Description** of the issue
4. **Impact** if left unfixed
5. **Recommended fix** with code example where applicable
---
## SECTION 1: SECURITY AUDIT
### 1.1 Credential Storage (CRITICAL PRIORITY)
Audit `/lib/services/settings_service.dart` for:
- [ ] Plaintext password storage in SharedPreferences (lines 209-222)
- [ ] Auth tokens stored without encryption
- [ ] Serialized credentials in JSON format (lines 169-190)
**Expected Finding:** Credentials stored insecurely. Recommend flutter_secure_storage.
### 1.2 Certificate Validation
Audit `/lib/services/auth/auth_manager.dart` for:
- [ ] HTTPS redirect handling for local IPs
- [ ] Certificate validation disabled scenarios
- [ ] MITM vulnerability exposure
### 1.3 Authentication Strategies
Audit all files in `/lib/services/auth/`:
- [ ] `basic_auth_strategy.dart` - Base64 encoding (not encryption)
- [ ] `authelia_strategy.dart` - Session cookie handling, timing attack (line 159)
- [ ] `ma_auth_strategy.dart` - Token refresh mechanism
- [ ] `no_auth_strategy.dart` - Public server exposure
### 1.4 Input Validation
Audit `/lib/services/music_assistant_api.dart`:
- [ ] URI construction without validation (lines 1346-1360)
- [ ] Provider/itemId injection risks
- [ ] WebSocket message validation
### 1.5 Information Disclosure
- [ ] Debug logs exposing sensitive data (check all `_logger.log()` calls)
- [ ] Server version exposure via `server_info`
- [ ] Error messages leaking technical details
---
## SECTION 2: MEMORY MANAGEMENT AUDIT
### 2.1 Static Memory Leaks
Audit `/lib/models/player.dart`:
- [ ] `_playerCreationTimes` static map (lines 96-144) - grows unbounded
- [ ] Cleanup only removes 50 of 100+ entries
### 2.2 Unbounded Caches
Audit `/lib/services/cache_service.dart`:
- [ ] `_albumTracksCache` - no size limit
- [ ] `_artistAlbumsCache` - no TTL expiration
- [ ] `_searchCache` - never evicted
- [ ] `_playerTrackCache` - only cleared on disconnect
### 2.3 Stream/Timer Cleanup
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] `_playerStateTimer` disposal (line 4435-4445)
- [ ] `_notificationPositionTimer` multiple creation paths (lines 2925-2940)
- [ ] `_localPlayerEventSubscription` cleanup
- [ ] `_playerUpdatedEventSubscription` cleanup
- [ ] `_playerAddedEventSubscription` cleanup
Audit `/lib/services/hardware_volume_service.dart`:
- [ ] `_volumeUpController` never closed (lines 74-85)
- [ ] `_volumeDownController` never closed
### 2.4 Stream Subscription Leaks
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] `_api!.connectionState.listen()` - subscription not stored
- [ ] Old subscriptions not cancelled before new API instance
---
## SECTION 3: RACE CONDITION AUDIT
### 3.1 Player State Dual Updates
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] Timer polling (every 5s) vs WebSocket events racing
- [ ] `_updatePlayerState()` called from both paths
- [ ] No mutex/lock protection
### 3.2 Async Method Reentrancy
Audit `selectPlayer()` method:
- [ ] `async` method without guard against concurrent calls
- [ ] `_selectedPlayer` modified before and after `await`
- [ ] State could change during await
### 3.3 Fire-and-Forget Operations
Search for `unawaited(` and `() async {` patterns:
- [ ] `_persistPlaybackState()` - database save not awaited
- [ ] Pause operations fire-and-forget (lines 3876-3894)
- [ ] Preload operations (line 2707)
### 3.4 PCM Player State Machine
Audit `/lib/services/pcm_audio_player.dart`:
- [ ] 9-state machine with complex transitions (lines 32-37)
- [ ] Auto-recovery race condition (lines 215-229)
- [ ] Feed loop during pause transition (lines 174-179)
---
## SECTION 4: ERROR HANDLING AUDIT
### 4.1 Silent Exception Handlers (CRITICAL)
Find all instances of:
```dart
} catch (e) {}
} catch (_) {}
```
Known locations:
- [ ] `music_assistant_provider.dart` (multiple)
- [ ] `player_provider.dart` (3 instances)
- [ ] `recently_played_service.dart`
### 4.2 Missing Error Recovery
- [ ] No exponential backoff on connection retry
- [ ] No retry logic for failed API calls
- [ ] WebSocket timeout handling (30 seconds - too long?)
### 4.3 Null Safety Violations
Find all `!` (null assertion) operators:
- [ ] `search_screen.dart` (20+ instances)
- [ ] `models/media_item.dart`
- [ ] `models/player.dart`
- [ ] `item.mediaItem! as Artist` patterns
---
## SECTION 5: ARCHITECTURAL ISSUES
### 5.1 God Class
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] 4,469 lines - violates Single Responsibility
- [ ] 386 conditional branches
- [ ] Mixing: connection, players, library, sync, audio, position tracking
**Recommend splitting into:**
- ConnectionProvider (~500 LOC)
- PlayerManager (~800 LOC)
- LibraryProvider (~1000 LOC)
- LocalPlaybackManager (~600 LOC)
### 5.2 Oversized Widget Files
- [ ] `expandable_player.dart` - 2,485 lines (should be <500)
- [ ] `global_player_overlay.dart` - 737 lines
- [ ] `new_library_screen.dart` - 2,521 lines
### 5.3 Global Key Anti-Pattern
Audit `/lib/widgets/global_player_overlay.dart`:
- [ ] `globalPlayerKey` for state access
- [ ] `_overlayStateKey` for overlay state
- [ ] Should use Provider/context instead
### 5.4 Primitive Obsession
- [ ] View modes as strings ('grid2', 'grid3', 'list')
- [ ] Media types as strings ('track', 'album', 'artist')
- [ ] Should be strongly-typed enums
---
## SECTION 6: CODE QUALITY ISSUES
### 6.1 Testing (CRITICAL)
- [ ] **ZERO test files exist** - confirm this
- [ ] No unit tests, widget tests, or integration tests
- [ ] flutter_test dependency exists but unused
### 6.2 Hardcoded Values
Find and document all:
- [ ] Color hex codes (50+ instances of `Color(0xFF...`)
- [ ] Duration values not using `Timings` constants
- [ ] Magic numbers (16, 4, 5, 100, 50)
- [ ] String literals for media types
### 6.3 Mixed Async Patterns
- [ ] `.then()` chains alongside async/await
- [ ] `.whenComplete()` usage
- [ ] Consolidate to async/await
### 6.4 Dead Code
- [ ] `@Deprecated` methods still implemented
- [ ] `animation_debugger.dart` - dev-only?
- [ ] Unused imports
### 6.5 TODO Comments
Document all actionable TODOs:
- [ ] `queue_screen.dart:214` - queue item removal
- [ ] `audiobook_detail_screen.dart:261,312` - progress sync
- [ ] `music_assistant_provider.dart:793,799` - playlist modification
---
## SECTION 7: UI/UX AUDIT
### 7.1 Accessibility (CRITICAL)
Audit for missing:
- [ ] `Semantics` widgets on interactive elements
- [ ] `Tooltip` on icon buttons
- [ ] Screen reader support
- [ ] Touch target sizes (minimum 48dp)
- [ ] Color contrast validation (WCAG AA)
### 7.2 Performance Issues
Audit `/lib/widgets/expandable_player.dart`:
- [ ] No `RepaintBoundary` wrappers
- [ ] 55+ `setState()` calls
- [ ] 3 AnimationControllers with multiple listeners
- [ ] Title height cache invalidation (lines 118+)
### 7.3 Double Opacity Bug
Audit `/lib/widgets/player/player_controls.dart`:
```dart
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
```
- [ ] Double `.withOpacity()` application
### 7.4 Missing Features
- [ ] No landscape orientation support
- [ ] No tablet layout optimization
- [ ] No loading skeleton animations
- [ ] No haptic feedback (except one location)
---
## SECTION 8: AUDIO PLAYBACK AUDIT
### 8.1 Position Interpolation
Audit `/lib/services/position_tracker.dart`:
- [ ] No timeout on anchor updates - unbounded drift possible
- [ ] Duration interpolation can exceed track duration
- [ ] Stale timestamp handling (>30 seconds)
### 8.2 Sendspin Protocol
Audit `/lib/services/sendspin_service.dart`:
- [ ] Proxy auth retry without deduplication (lines 188-209)
- [ ] WebSocket binary frame parsing
- [ ] Clock synchronization accuracy
### 8.3 Audio Resource Cleanup
Audit `/lib/services/audio/massiv_audio_handler.dart`:
- [ ] Audio session interrupt listeners not disposed
- [ ] Audio focus not explicitly released
### 8.4 Mixed Playback Modes
- [ ] just_audio initialized but unused during Sendspin
- [ ] Resource allocation for unused player
- [ ] Potential audio focus conflicts
---
## SECTION 9: NETWORKING AUDIT
### 9.1 WebSocket Lifecycle
Audit `/lib/services/music_assistant_api.dart`:
- [ ] Heartbeat interval (30s) - appropriate?
- [ ] Reconnection logic and timing
- [ ] Pending request cleanup on disconnect
### 9.2 Caching Strategy
- [ ] No incremental/delta sync (full library every 5 minutes)
- [ ] Cache staleness not communicated to UI
- [ ] No query result pagination (fetches up to 5000 items)
### 9.3 Rate Limiting
- [ ] No client-side rate limiting
- [ ] Search requests not throttled
- [ ] Could overload server
### 9.4 Offline Support
Audit `/lib/services/offline_action_queue.dart`:
- [ ] Queue execution not atomic (lines 118-130)
- [ ] Action could execute twice on crash
- [ ] Queue stored unencrypted
---
## SECTION 10: DATABASE AUDIT
### 10.1 Schema Review
Audit `/lib/database/database.dart`:
- [ ] Table indexes for query performance
- [ ] Foreign key constraints
- [ ] Migration strategy
### 10.2 Query Patterns
- [ ] N+1 query prevention
- [ ] Transaction usage for atomic operations
- [ ] Connection pooling
### 10.3 Data Consistency
- [ ] Tier 1 (DB) vs Tier 2 (API sync) race conditions
- [ ] User modifications during background sync
- [ ] Conflict resolution strategy
---
## Deliverables
After completing the audit, provide:
### 1. Executive Summary
- Overall code quality score (1-10)
- Top 5 critical issues requiring immediate attention
- Estimated technical debt in developer-hours
### 2. Issue Tracker
Create a prioritized list of all issues found:
| ID | Severity | Category | File:Line | Description | Effort |
|----|----------|----------|-----------|-------------|--------|
### 3. Recommended Roadmap
Sprint-based plan to address findings:
- Sprint 1 (Immediate): Security fixes, silent exception handlers
- Sprint 2-3: God class decomposition, test infrastructure
- Sprint 4-6: Memory leak fixes, accessibility
- Long-term: Performance optimization, monitoring
### 4. Code Examples
For each CRITICAL and HIGH severity issue, provide:
- Current problematic code
- Recommended refactored code
- Migration steps
---
## Files to Prioritize
Start your audit with these critical files:
1. `/lib/providers/music_assistant_provider.dart` (4,469 LOC) - God class
2. `/lib/services/settings_service.dart` - Credential storage
3. `/lib/services/music_assistant_api.dart` (2,929 LOC) - Networking
4. `/lib/widgets/expandable_player.dart` (2,485 LOC) - UI complexity
5. `/lib/services/auth/` directory - Security
6. `/lib/models/player.dart` - Memory leaks
7. `/lib/services/cache_service.dart` - Unbounded caches
8. `/lib/services/position_tracker.dart` - Audio sync issues
---
## Known Critical Issues (Pre-Identified)
These issues have been identified and MUST be verified and documented:
1. **CRITICAL: Plaintext credential storage** - SharedPreferences without encryption
2. **CRITICAL: Zero test coverage** - No unit, widget, or integration tests
3. **CRITICAL: 4,469 line god class** - MusicAssistantProvider violates SRP
4. **CRITICAL: Silent exception handlers** - `catch (e) {}` patterns
5. **HIGH: Static memory leak** - `_playerCreationTimes` unbounded growth
6. **HIGH: Race conditions** - Player state polling vs events
7. **HIGH: No accessibility** - Missing Semantics, screen reader support
8. **HIGH: Certificate validation disabled** - MITM vulnerability for local IPs
9. **MEDIUM: Double opacity bug** - `.withOpacity().withOpacity()` in player controls
10. **MEDIUM: Position tracker drift** - No timeout on anchor updates
---
## Audit Execution Notes
- Use `Grep` tool to search for patterns across the codebase
- Use `Read` tool to examine specific file sections
- Document line numbers for all findings
- Provide code snippets for context
- Cross-reference related issues across files
- Verify pre-identified issues exist and document their exact locations
**Total estimated audit time: 4-6 hours for thorough coverage**
+419
View File
@@ -0,0 +1,419 @@
# Ensemble Codebase - Comprehensive Audit Report
**Audit Date:** 2026-01-01
**Codebase:** Ensemble Flutter App (Music Assistant Client)
**Version:** 2.7.3-beta+35
**Total Lines of Code:** ~40,583 Dart
**Files Analyzed:** 90 Dart files
---
## Executive Summary
### Overall Code Quality Score: 5.5/10
The Ensemble codebase demonstrates **strong architectural foundations** with a well-organized service layer, effective caching strategies, and sophisticated audio handling. However, the audit revealed **critical security vulnerabilities**, **zero test coverage**, and significant **technical debt** that must be addressed before production release.
### Critical Statistics
| Metric | Value | Status |
|--------|-------|--------|
| Test Coverage | 0% | CRITICAL |
| God Class Size | 4,469 LOC | CRITICAL |
| Silent Exception Handlers | 7 | CRITICAL |
| Plaintext Credentials | 4 locations | CRITICAL |
| Memory Leak Locations | 6 | HIGH |
| Race Conditions | 4 | HIGH |
| Accessibility (Semantics) | 0 widgets | CRITICAL |
---
## Issue Summary by Severity
| Severity | Count | Categories |
|----------|-------|------------|
| **CRITICAL** | 12 | Security, Testing, Architecture, Accessibility |
| **HIGH** | 18 | Memory, Race Conditions, Networking, Audio |
| **MEDIUM** | 22 | Error Handling, UI, Caching, Code Quality |
| **LOW** | 14 | Naming, Documentation, Minor Improvements |
---
## CRITICAL ISSUES (Immediate Action Required)
### 1. Plaintext Credential Storage
**File:** `/lib/services/settings_service.dart`
**Lines:** 131-143, 147-159, 169-190, 209-222
Passwords and auth tokens stored in SharedPreferences without encryption.
```dart
// VULNERABLE CODE
static Future<void> setPassword(String? password) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyPassword, password); // PLAINTEXT!
}
```
**Fix:** Use `flutter_secure_storage` for all sensitive data.
---
### 2. Zero Test Coverage
**Location:** Entire codebase
No unit tests, widget tests, or integration tests exist. The `flutter_test` dependency is unused.
**Impact:** Cannot safely refactor, high regression risk, no confidence in correctness.
**Fix:** Establish test infrastructure immediately. Start with:
- Unit tests for `MusicAssistantAPI`, `CacheService`, `ErrorHandler`
- Widget tests for player controls
- Integration tests for playback flows
---
### 3. God Class - MusicAssistantProvider
**File:** `/lib/providers/music_assistant_provider.dart`
**Size:** 4,469 lines, ~140 methods, 744 conditional branches
Single class handles: connection, players, library, cache, audio, sync, offline queue.
**Fix:** Split into:
- `ConnectionManager` (~500 LOC)
- `PlayerManager` (~800 LOC)
- `LibraryCacheService` (~600 LOC)
- `SendspinManager` (~400 LOC)
- `MusicAssistantProvider` - Facade composing above (~800 LOC)
---
### 4. Zero Accessibility Support
**Files:** `/lib/widgets/`, `/lib/screens/` (all)
- **0** `Semantics` widgets
- **0** `semanticLabel` properties
- **1** `Tooltip` (entire app)
- Screen readers cannot navigate the app
**Fix:** Add Semantics to all interactive elements:
```dart
Semantics(
button: true,
label: isPlaying ? 'Pause playback' : 'Play',
child: IconButton(...)
)
```
---
### 5. Silent Exception Handlers
**Locations:** 7 instances across codebase
| File | Line | Code |
|------|------|------|
| player_provider.dart | 567, 575, 584 | `catch (e) {}` |
| music_assistant_provider.dart | 2690 | `catch (e) {}` |
| music_assistant_api.dart | 2542 | `catch (e) {}` |
| recently_played_service.dart | 154, 206 | `catch (_) {}` |
**Fix:** Add logging or use `firstWhereOrNull` instead of try/catch around `firstWhere`.
---
### 6. Audio Session Listeners Not Disposed
**File:** `/lib/services/audio/massiv_audio_handler.dart`
**Lines:** 45-73
Stream subscriptions created but never cancelled - causes memory leaks and zombie listeners.
**Fix:** Store subscriptions and cancel in dispose():
```dart
StreamSubscription? _interruptionSubscription;
_interruptionSubscription = session.interruptionEventStream.listen(...);
void dispose() {
_interruptionSubscription?.cancel();
}
```
---
## HIGH SEVERITY ISSUES
### 7. Connection State Subscription Not Stored
**File:** `/lib/providers/music_assistant_provider.dart:575-614`
The `connectionState.listen()` return value is not stored - causes listener accumulation on reconnect.
---
### 8. Player State Race Condition
**File:** `/lib/providers/music_assistant_provider.dart`
Timer polling (every 5s) and WebSocket events can race to update `_selectedPlayer` and `_currentTrack` without mutex protection.
---
### 9. Async Method Reentrancy
**File:** `/lib/providers/music_assistant_provider.dart:2713-2841`
`selectPlayer()` is `void async` with no guard against concurrent calls. Multiple rapid player switches can interleave.
---
### 10. Unbounded Search Cache
**File:** `/lib/services/cache_service.dart:31-32`
`_searchCache` map has no size limit or proactive TTL eviction. Grows indefinitely with user searches.
---
### 11. Static Memory Leak
**File:** `/lib/models/player.dart:96, 139-144`
`_playerCreationTimes` static map cleanup only removes 50 of 100+ entries, allowing unbounded growth.
---
### 12. No Database Indexes
**File:** `/lib/database/database.dart`
No secondary indexes defined. Query performance degrades as database grows.
---
### 13. No Incremental Sync
**File:** `/lib/services/sync_service.dart:150-165`
Full library fetch (up to 4000 items) on every sync instead of delta updates.
---
### 14. Offline Queue Not Atomic
**File:** `/lib/services/offline_action_queue.dart:80-108`
Queue persistence happens after all processing - crash during processing loses successful actions.
---
### 15. Auth Retry Without Deduplication
**File:** `/lib/services/sendspin_service.dart:189-209`
Multiple auth messages can be sent during rapid reconnection attempts.
---
### 16. Pending Requests Not Completed on Disconnect
**File:** `/lib/services/music_assistant_api.dart:2911`
`_pendingRequests.clear()` abandons Completers without completing them with errors.
---
### 17. HTTP Fallback for Local IPs
**File:** `/lib/services/auth/auth_manager.dart:51-58`
HTTPS failures silently fall back to HTTP on local networks - MITM vulnerability.
---
### 18. Certificate Validation Bypassed
**File:** `/lib/services/auth/auth_manager.dart:210-236`
HTTPS redirects explicitly refused for local IPs due to expected certificate failures.
---
## MEDIUM SEVERITY ISSUES
### 19. Double Opacity Bug
**File:** `/lib/widgets/player/player_controls.dart:62-63, 99-100`
```dart
// BUG: .withOpacity(0.5).withOpacity(expandedElementsOpacity) compounds opacity
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
```
---
### 20. Mixed Async Patterns
**Locations:** 13 instances
`.then()` chains mixed with `async/await` throughout the codebase.
---
### 21. Hardcoded Colors
**Locations:** 55 instances
`Color(0xFF604CEC)`, `Color(0xFF1a1a1a)` scattered across theme files and widgets.
---
### 22. View Modes as Strings
**Locations:** 80+ instances
`'grid2'`, `'grid3'`, `'list'` should be enums.
---
### 23. Fire-and-Forget Pause Commands
**File:** `/lib/providers/music_assistant_provider.dart:3875-3894`
Server pause command uses `unawaited()` - errors are silently lost.
---
### 24. Position Tracker Anchor Timeout Missing
**File:** `/lib/services/position_tracker.dart:24-26`
No timeout on stale anchor - interpolation can drift indefinitely.
---
### 25. PCM Buffer No Overflow Protection
**File:** `/lib/services/pcm_audio_player.dart:68, 236-247`
Audio buffer grows unbounded if data arrives faster than consumption.
---
### 26. No Client-Side Rate Limiting
**Location:** App-wide
No throttling on API requests during rapid user interactions.
---
### 27. Portrait-Only Lock
**File:** `/lib/main.dart:64-68`
No landscape orientation support.
---
### 28. Touch Targets Below 48dp
**Files:** player_controls.dart, queue_panel.dart, player_card.dart
Multiple buttons at 28x28 and 44x44 instead of minimum 48x48.
---
### 29. WCAG Contrast Not Validated
**File:** `/lib/theme/palette_helper.dart`
Uses 0.4 threshold instead of proper WCAG 4.5:1 contrast ratio calculation.
---
### 30. StreamControllers Not Closed
**File:** `/lib/services/hardware_volume_service.dart:18-19, 74-85`
`_volumeUpController` and `_volumeDownController` never closed in dispose().
---
## LOW SEVERITY ISSUES
### 31-34. Naming Inconsistencies
Mixed patterns: `_isLoading` vs `_isLoadingPlaylists`, various cache variable names.
### 35-38. Missing TODO Implementations
6 actionable TODOs identified (queue removal, audiobook progress sync, playlist modification).
### 39-42. Dead/Unused Code
- `@Deprecated` methods still implemented
- `library_stats.dart` appears unused
- `animation_debugger.dart` disabled by default
### 43-44. Minor Documentation Gaps
Missing method documentation, no architecture diagram.
---
## Recommended Remediation Roadmap
### Sprint 1 (Week 1-2): Security & Critical Fixes
1. Migrate credentials to `flutter_secure_storage`
2. Add logging to all silent catch blocks
3. Store connection state subscription
4. Fix audio session listener disposal
5. Add basic test infrastructure (10% coverage target)
### Sprint 2 (Week 3-4): Memory & Race Conditions
6. Add mutex to player state updates
7. Add reentrancy guard to `selectPlayer()`
8. Implement bounded caches with LRU eviction
9. Fix static memory leak in Player model
10. Store and cancel all stream subscriptions
### Sprint 3 (Week 5-6): Architecture Refactoring
11. Split `MusicAssistantProvider` into 5 smaller classes
12. Split `expandable_player.dart` into component widgets
13. Replace GlobalKey anti-patterns with Provider
14. Convert string constants to enums
15. Extract hardcoded colors to constants file
### Sprint 4 (Week 7-8): Accessibility & UI
16. Add Semantics to all interactive elements
17. Increase touch targets to 48dp minimum
18. Implement WCAG contrast validation
19. Fix double opacity bug
20. Add landscape orientation support
### Sprint 5 (Week 9-10): Performance & Polish
21. Add database indexes
22. Implement incremental sync
23. Add client-side rate limiting
24. Fix atomic offline queue processing
25. Achieve 30% test coverage target
---
## Files Requiring Most Attention
| File | LOC | Issues | Priority |
|------|-----|--------|----------|
| `music_assistant_provider.dart` | 4,469 | 12 | CRITICAL |
| `settings_service.dart` | 811 | 4 | CRITICAL |
| `expandable_player.dart` | 2,485 | 6 | HIGH |
| `music_assistant_api.dart` | 2,929 | 5 | HIGH |
| `cache_service.dart` | 370 | 4 | HIGH |
| `massiv_audio_handler.dart` | 296 | 2 | CRITICAL |
| `player.dart` | 422 | 2 | HIGH |
| `auth_manager.dart` | 300 | 3 | HIGH |
---
## Estimated Technical Debt
| Category | Effort (Dev-Days) |
|----------|-------------------|
| Security Fixes | 3-5 |
| Test Infrastructure | 10-15 |
| Architecture Refactoring | 15-20 |
| Memory Leak Fixes | 3-5 |
| Accessibility | 5-8 |
| UI/UX Polish | 3-5 |
| Performance | 5-8 |
| **Total** | **44-66 dev-days** |
---
## Conclusion
The Ensemble app has a solid foundation with well-designed patterns for caching, audio handling, and multi-device synchronization. However, **critical security vulnerabilities** (plaintext credentials), **zero test coverage**, and a **4,469-line god class** represent significant risks that must be addressed before any production deployment.
The most urgent priorities are:
1. **Secure credential storage** - immediate security risk
2. **Add test infrastructure** - enables safe refactoring
3. **Split the god class** - enables maintainability
4. **Add accessibility** - legal/compliance requirement
With focused effort over 8-10 weeks, the codebase can be brought to production-quality standards.
---
*Audit conducted using 6 parallel specialized sub-agents analyzing security, memory management, race conditions, architecture, UI/UX, and audio/networking.*
+52
View File
@@ -0,0 +1,52 @@
# Search Improvements
Branch: `feature/search-improvements`
## Completed
- [x] Add playlists and audiobooks to search results
- Parse from MA API response
- Filter chips for each type
- Tile builders with navigation to detail screens
- [x] Unified Relevance-Based "All" Results
- Scoring algorithm implemented:
- Exact match: 100 points
- Starts with query: 80 points
- Contains query (word boundary): 60 points
- Contains query (anywhere): 40 points
- Fuzzy match: 20 points
- Bonus for library items (albums): +10 points
- Bonus for favorites: +5 points
- Secondary field matching (artist name in albums/tracks)
- Merged all results into single sorted list
- Type indicators in subtitle (e.g., "Artist • Album")
- [x] Smart Cross-Referencing
- Extracts unique artists from matched tracks/albums
- Filters to artists whose name contains query words (min 3 chars)
- Adds cross-referenced artists with lower relevance score (25)
- Avoids duplicates with direct search results
- [x] Past Searches Row
- Added SearchHistory table to database (schema v4)
- Saves successful searches (deduplicated, max 10)
- Displays as ActionChips in empty state
- Clicking chip performs that search
## Remaining Ideas (Lower Priority)
- **Library-only toggle:** Add switch to filter search to library items only (MA API supports this)
- **Voice search:** Add microphone button if platform supports speech recognition
- **Search suggestions:** Typeahead from library as user types
- **Quick actions:** Long-press result for play/queue options without navigating
- **Clear history in settings:** Add option to clear search history
## Files Modified
| File | Changes |
|------|---------|
| `lib/database/database.dart` | Added SearchHistory table, schema v4 |
| `lib/services/database_service.dart` | Added search history methods |
| `lib/screens/search_screen.dart` | Relevance scoring, cross-refs, history UI |
| `lib/l10n/app_en.arb` | Added localization strings |
+128 -2
View File
@@ -25,6 +25,8 @@ class Profiles extends Table {
}
/// Recently played items - scoped by profile
@TableIndex(name: 'idx_recently_played_profile', columns: {#profileUsername})
@TableIndex(name: 'idx_recently_played_profile_played', columns: {#profileUsername, #playedAt})
class RecentlyPlayed extends Table {
/// Auto-incrementing ID
IntColumn get id => integer().autoIncrement()();
@@ -55,6 +57,8 @@ class RecentlyPlayed extends Table {
}
/// Cached library items for fast startup
@TableIndex(name: 'idx_library_cache_type', columns: {#itemType})
@TableIndex(name: 'idx_library_cache_type_deleted', columns: {#itemType, #isDeleted})
class LibraryCache extends Table {
/// Composite key: provider + item_id
TextColumn get cacheKey => text()();
@@ -139,6 +143,8 @@ class CachedPlayers extends Table {
}
/// Cached queue items for selected player
@TableIndex(name: 'idx_cached_queue_player', columns: {#playerId})
@TableIndex(name: 'idx_cached_queue_player_position', columns: {#playerId, #position})
class CachedQueue extends Table {
/// Auto-incrementing ID for ordering
IntColumn get id => integer().autoIncrement()();
@@ -153,12 +159,39 @@ 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};
}
/// Search history for quick access to recent searches
class SearchHistory extends Table {
/// Auto-incrementing ID
IntColumn get id => integer().autoIncrement()();
/// The search query
TextColumn get query => text()();
/// When the search was performed
DateTimeColumn get searchedAt => dateTime()();
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 2;
int get schemaVersion => 5;
@override
MigrationStrategy get migration {
@@ -173,6 +206,26 @@ 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);
}
// Migration from v3 to v4: Add search history table
if (from < 4) {
await m.createTable(searchHistory);
}
// Migration from v4 to v5: Add indexes for query performance
if (from < 5) {
// RecentlyPlayed indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_recently_played_profile ON recently_played (profile_username)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_recently_played_profile_played ON recently_played (profile_username, played_at)');
// LibraryCache indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_library_cache_type ON library_cache (item_type)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_library_cache_type_deleted ON library_cache (item_type, is_deleted)');
// CachedQueue indexes
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)');
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)');
}
},
);
}
@@ -524,4 +577,77 @@ 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();
}
// ============================================
// Search History Operations
// ============================================
/// Save a search query to history (deduplicates and limits to 10)
Future<void> saveSearchQuery(String query) async {
final trimmedQuery = query.trim();
if (trimmedQuery.isEmpty) return;
await transaction(() async {
// Delete existing entry with same query (case-insensitive)
await (delete(searchHistory)
..where((s) => s.query.lower().equals(trimmedQuery.toLowerCase())))
.go();
// Insert the new search
await into(searchHistory).insert(SearchHistoryCompanion.insert(
query: trimmedQuery,
searchedAt: DateTime.now(),
));
// Keep only the 10 most recent searches
final allSearches = await (select(searchHistory)
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)]))
.get();
if (allSearches.length > 10) {
final toDelete = allSearches.skip(10).map((s) => s.id).toList();
await (delete(searchHistory)..where((s) => s.id.isIn(toDelete))).go();
}
});
}
/// Get recent search queries (up to 10, most recent first)
Future<List<String>> getRecentSearches() async {
final searches = await (select(searchHistory)
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)])
..limit(10))
.get();
return searches.map((s) => s.query).toList();
}
/// Clear all search history
Future<void> clearSearchHistory() async {
await delete(searchHistory).go();
}
}
+9 -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}",
@@ -278,7 +279,14 @@
"playerStateUnavailable": "Nicht verfügbar",
"playerStateOff": "Aus",
"playerStateIdle": "Inaktiv",
"playerStateExternalSource": "Externe Quelle",
"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": "Gedrückt halten zum Sync",
"swipeToAdjustVolume": "Wischen für Lautstärke"
}
+101 -1
View File
@@ -261,6 +261,26 @@
"description": "Search hint text"
},
"recentSearches": "Recent Searches",
"@recentSearches": {
"description": "Label for recent search history section"
},
"clearSearchHistory": "Clear Search History",
"@clearSearchHistory": {
"description": "Button to clear search history"
},
"searchHistoryCleared": "Search history cleared",
"@searchHistoryCleared": {
"description": "Confirmation message after clearing search history"
},
"libraryOnly": "Library only",
"@libraryOnly": {
"description": "Toggle to search only in library"
},
"retry": "Retry",
"@retry": {
"description": "Retry button label"
@@ -610,6 +630,11 @@
"description": "Radio button label"
},
"addedToQueue": "Added to queue",
"@addedToQueue": {
"description": "Confirmation when item added to queue"
},
"inLibrary": "In Library",
"@inLibrary": {
"description": "In library section title"
@@ -635,6 +660,11 @@
"description": "Status when playing track"
},
"nowPlaying": "Now playing",
"@nowPlaying": {
"description": "Label shown when device selector is open to indicate current player"
},
"markedAsFinished": "{name} marked as finished",
"@markedAsFinished": {
"description": "Audiobook marked finished",
@@ -1073,7 +1103,7 @@
"description": "Hint for Last.fm API key field"
},
"swipeToSwitchDevice": "Swipe to switch device",
"swipeToSwitchDevice": "Swipe to change player",
"@swipeToSwitchDevice": {
"description": "Hint for device switching gesture"
},
@@ -1128,6 +1158,16 @@
"description": "Singular form of audiobook"
},
"albumSingular": "Album",
"@albumSingular": {
"description": "Singular form of album for search type indicator"
},
"trackSingular": "Track",
"@trackSingular": {
"description": "Singular form of track for search type indicator"
},
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
"@trackCount": {
"description": "Track count with plural",
@@ -1188,6 +1228,11 @@
"description": "Player state when idle"
},
"playerStateExternalSource": "External Source",
"@playerStateExternalSource": {
"description": "Player state when playing from external source (optical, Spotify, etc.)"
},
"playerSelected": "Selected",
"@playerSelected": {
"description": "Label for selected player"
@@ -1206,5 +1251,60 @@
"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": "Long-press to sync",
"@holdToSync": {
"description": "Hint for long-press to sync players"
},
"swipeToAdjustVolume": "Swipe to adjust volume",
"@swipeToAdjustVolume": {
"description": "Hint for swiping left/right to adjust player volume"
},
"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"
}
}
+10 -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": {
@@ -474,7 +475,14 @@
"playerStateUnavailable": "No disponible",
"playerStateOff": "Apagado",
"playerStateIdle": "Inactivo",
"playerStateExternalSource": "Fuente Externa",
"playerSelected": "Seleccionado",
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
"pendingOfflineActions": "{count} sincronización pendiente"
"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 para sync",
"swipeToAdjustVolume": "Desliza para ajustar volumen"
}
+58 -5
View File
@@ -40,6 +40,10 @@ Future<void> main() async {
// Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName();
// Migrate credentials to secure storage (one-time for existing users)
await SettingsService.migrateToSecureStorage();
_logger.log('🔐 Secure storage migration complete');
// Load library from cache for instant startup
await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded');
@@ -348,7 +352,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 +374,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 +424,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 +474,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(
+123 -7
View File
@@ -12,6 +12,11 @@ class Player {
final bool? volumeMuted;
final double? elapsedTime; // Seconds elapsed in current track
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
final List<String>? groupMembers; // List of player IDs in sync group (includes self)
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
Player({
required this.playerId,
@@ -25,6 +30,11 @@ class Player {
this.volumeMuted,
this.elapsedTime,
this.elapsedTimeLastUpdated,
this.groupMembers,
this.syncedTo,
this.activeSource,
this.isExternalSource = false,
this.appId,
});
/// Create a copy of this Player with some fields replaced
@@ -40,6 +50,11 @@ class Player {
bool? volumeMuted,
double? elapsedTime,
double? elapsedTimeLastUpdated,
List<String>? groupMembers,
String? syncedTo,
String? activeSource,
bool? isExternalSource,
String? appId,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -53,6 +68,11 @@ class Player {
volumeMuted: volumeMuted ?? this.volumeMuted,
elapsedTime: elapsedTime ?? this.elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
groupMembers: groupMembers ?? this.groupMembers,
syncedTo: syncedTo ?? this.syncedTo,
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId,
);
}
@@ -61,8 +81,37 @@ 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;
// A player is manually synced if it's synced TO another player (child of a sync group)
// This excludes pre-configured MA speaker groups which have groupMembers but no syncedTo
// Used for yellow border highlight - only shows for players the user manually synced
bool get isManuallySynced => syncedTo != null;
// Track when this Player object was created (for local interpolation fallback)
static final Map<String, double> _playerCreationTimes = {};
static const int _maxCreationTimesEntries = 50;
/// Clean up old creation time entries using LRU eviction
static void _cleanupCreationTimes() {
if (_playerCreationTimes.length <= _maxCreationTimesEntries) return;
// Sort by timestamp value (oldest first) for true LRU eviction
final sortedEntries = _playerCreationTimes.entries.toList()
..sort((a, b) => a.value.compareTo(b.value));
// Remove oldest entries until we're at target size
final entriesToRemove = sortedEntries.take(
_playerCreationTimes.length - _maxCreationTimesEntries,
);
for (final entry in entriesToRemove) {
_playerCreationTimes.remove(entry.key);
}
}
// Calculate current elapsed time (interpolated if playing)
double get currentElapsedTime {
@@ -104,13 +153,8 @@ class Player {
if (!_playerCreationTimes.containsKey(creationKey)) {
// First time seeing this elapsed_time - record when we saw it
_playerCreationTimes[creationKey] = now;
// Clean up old entries to prevent memory leak
if (_playerCreationTimes.length > 100) {
final keysToRemove = _playerCreationTimes.keys.take(50).toList();
for (final key in keysToRemove) {
_playerCreationTimes.remove(key);
}
}
// Clean up old entries to prevent memory leak (LRU eviction)
_cleanupCreationTimes();
}
final creationTime = _playerCreationTimes[creationKey]!;
@@ -164,6 +208,68 @@ class Player {
elapsedTime ??= topLevelElapsedTime;
elapsedTimeLastUpdated ??= topLevelLastUpdated;
// Parse group members - MA returns this as a list of player IDs
final groupMembersList = json['group_members'] as List<dynamic>?;
final groupMembers = groupMembersList?.map((e) => e.toString()).toList();
// Parse synced_to - the player ID this player is synced to
final syncedTo = json['synced_to'] as String?;
// Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.)
// Primary indicator: app_id - if not 'music_assistant', it's an external source
// Secondary indicators: URI patterns and media_type
bool isExternalSource = false;
// Check app_id first - this is the most reliable indicator for DLNA players
// When MA is playing: app_id = 'music_assistant'
// When external source: app_id = 'http', 'spotify', etc.
final appId = json['app_id'] as String?;
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
// app_id is set but not 'music_assistant' - external source is active
isExternalSource = true;
}
// Also check current_media for additional external source indicators
if (!isExternalSource && json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?;
// External source indicators:
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
if (uri != null) {
final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /)
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
}
// Also check media_type - 'unknown' often indicates external source
if (!isExternalSource && mediaType == 'unknown') {
// If media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true;
}
}
}
}
return Player(
playerId: json['player_id'] as String,
name: json['name'] as String,
@@ -176,6 +282,11 @@ class Player {
volumeMuted: json['volume_muted'] as bool?,
elapsedTime: elapsedTime,
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
groupMembers: groupMembers,
syncedTo: syncedTo,
activeSource: activeSource,
isExternalSource: isExternalSource,
appId: appId,
);
}
@@ -192,6 +303,11 @@ class Player {
if (volumeMuted != null) 'volume_muted': volumeMuted,
if (elapsedTime != null) 'elapsed_time': elapsedTime,
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
if (groupMembers != null) 'group_members': groupMembers,
if (syncedTo != null) 'synced_to': syncedTo,
if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId,
};
}
}
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -559,28 +559,28 @@ class PlayerProvider with ChangeNotifier {
if (playerToSelect == null) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Priority 1: Last selected player
if (lastSelectedPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == lastSelectedPlayerId && p.available,
);
} catch (e) {}
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
}
// Priority 2: Built-in (local) player
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
} catch (e) {}
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == builtinPlayerId && p.available,
orElse: () => null,
);
}
// Priority 3: Currently playing player (exclude external sources - not playing MA content)
if (playerToSelect == null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.state == 'playing' && p.available,
);
} catch (e) {}
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.state == 'playing' && p.available && !p.isExternalSource,
orElse: () => null,
);
}
if (playerToSelect == null) {
@@ -906,10 +906,10 @@ class PlayerProvider with ChangeNotifier {
}
}
Future<void> toggleShuffle(String queueId) async {
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
if (_api == null) return;
try {
await _api!.toggleShuffle(queueId);
await _api!.toggleShuffle(queueId, shuffleEnabled);
} catch (e) {
ErrorHandler.logError('Toggle shuffle', e);
rethrow;
+49 -97
View File
@@ -10,6 +10,7 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart';
import 'artist_details_screen.dart';
@@ -558,7 +559,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
},
),
),
const SizedBox(height: 16),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
@@ -703,10 +704,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
},
child: Scaffold(
backgroundColor: colorScheme.background,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 350, // Increased height for bigger art
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.background,
leading: IconButton(
@@ -727,11 +734,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Container(
width: 280, // Increased size
height: 280,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(16), // Slightly more rounded
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
@@ -749,7 +756,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: imageUrl == null
? Icon(
Icons.album_rounded,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
)
: null,
@@ -906,14 +913,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
],
),
const SizedBox(height: 24),
Text(
S.of(context)!.tracks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
],
),
),
@@ -1080,118 +1079,71 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
],
),
);
},
),
),
);
}
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
_showPlayOnSheet(
context,
players,
onPlayerSelected: (player) {
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.playTracks(
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: startIndex,
);
},
);
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final track = _tracks[trackIndex];
_showPlayOnSheet(
context,
players,
onPlayerSelected: (player) {
GlobalPlayerOverlay.hidePlayer();
showPlayerPickerSheet(
context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.playRadio(player.playerId, track);
await maProvider.playRadio(player.playerId, track);
},
);
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
_showPlayOnSheet(
context,
players,
onPlayerSelected: (player) {
maProvider.selectPlayer(player);
maProvider.playTracks(player.playerId, _tracks);
},
);
}
/// Shared "Play on..." bottom sheet with fixed height and scrollable list
void _showPlayOnSheet(
BuildContext context,
List players, {
required void Function(dynamic player) onPlayerSelected,
}) {
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding; // Fixed 45% + safe area
showModalBottomSheet(
showPlayerPickerSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
height: sheetHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? Center(
child: Text(S.of(context)!.noPlayersAvailable),
)
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () {
Navigator.pop(context);
onPlayerSelected(player);
},
);
},
),
),
],
),
),
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
await maProvider.playTracks(player.playerId, _tracks);
},
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
+117 -101
View File
@@ -243,57 +243,25 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
Future<void> _startArtistRadio() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadio(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
void _showPlayOnMenu(BuildContext context) {
void _showRadioMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => Padding(
padding: const EdgeInsets.all(16.0),
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge,
@@ -314,7 +282,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return ListTile(
leading: Icon(
Icons.speaker,
color: colorScheme.onSurface,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
@@ -345,46 +313,103 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
},
),
),
const SizedBox(height: 16),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
);
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
Future<void> _addArtistRadioToQueue() async {
void _showAddToQueueMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
final players = maProvider.availablePlayers;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
try {
// For adding to queue with radio mode, we use radio_mode but with 'add' option
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
final api = maProvider.api;
if (api == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.notConnected)),
);
}
return;
}
try {
await api.playArtistRadioToQueue(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding artist radio to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
Future<void> _loadArtistImage() async {
@@ -526,10 +551,16 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.background,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.background,
leading: IconButton(
@@ -549,8 +580,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
child: ClipOval(
child: Container(
width: 200,
height: 200,
width: coverSize,
height: coverSize,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
@@ -560,13 +591,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon(
Icons.person_rounded,
size: 100,
size: coverSize * 0.5,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.person_rounded,
size: 100,
size: coverSize * 0.5,
color: colorScheme.onSurfaceVariant,
),
),
@@ -627,7 +658,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _startArtistRadio,
onPressed: () => _showRadioMenu(context),
icon: const Icon(Icons.radio),
label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom(
@@ -640,23 +671,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
),
const SizedBox(width: 12),
// "Play on..." Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
@@ -665,7 +679,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _addArtistRadioToQueue,
onPressed: () => _showAddToQueueMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
@@ -839,6 +853,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
],
],
],
);
},
),
),
);
+28 -59
View File
@@ -5,6 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -209,62 +210,22 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding;
showModalBottomSheet(
showPlayerPickerSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
height: sheetHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 16),
Text(
S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? Center(child: Text(S.of(context)!.noPlayersAvailable))
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook);
await maProvider.api?.playAudiobook(
player.playerId,
widget.audiobook,
);
},
);
},
),
),
],
),
),
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.setCurrentAudiobook(_audiobook);
await maProvider.api?.playAudiobook(
player.playerId,
widget.audiobook,
);
},
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
@@ -466,10 +427,16 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 350,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -538,8 +505,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 280,
height: 280,
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
@@ -550,14 +517,14 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
@@ -565,7 +532,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
: Center(
child: Icon(
MdiIcons.bookOutline,
size: 120,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
@@ -809,6 +776,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
const SliverToBoxAdapter(child: SizedBox(height: 140)),
],
);
},
),
),
);
+14 -6
View File
@@ -275,11 +275,17 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
return Scaffold(
backgroundColor: colorScheme.surface,
body: CustomScrollView(
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 200-320
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
slivers: [
// App bar with series cover collage (matches audiobook detail screen)
SliverAppBar(
expandedHeight: 350,
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
@@ -292,12 +298,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
// Series cover collage - same size as audiobook detail cover
// Series cover collage - responsive size
Hero(
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: Container(
width: 280,
height: 280,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
@@ -445,10 +451,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
_buildAudiobookSliver(maProvider),
// Bottom padding for mini player
const SliverToBoxAdapter(
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
},
),
);
}
+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
File diff suppressed because it is too large Load Diff
+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,
+20 -4
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
@@ -11,6 +12,12 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
final AuthManager authManager;
final _logger = DebugLogger();
// Stream subscriptions for proper cleanup
StreamSubscription? _interruptionSubscription;
StreamSubscription? _becomingNoisySubscription;
StreamSubscription? _playbackEventSubscription;
StreamSubscription? _currentIndexSubscription;
// Track current metadata separately from what's in the notification
// This allows us to update the notification when metadata arrives late
MediaItem? _currentMediaItem;
@@ -42,7 +49,7 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
await session.configure(const AudioSessionConfiguration.music());
// Handle audio interruptions
session.interruptionEventStream.listen((event) {
_interruptionSubscription = session.interruptionEventStream.listen((event) {
if (event.begin) {
switch (event.type) {
case AudioInterruptionType.duck:
@@ -68,15 +75,15 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
});
// Handle becoming noisy (headphones unplugged)
session.becomingNoisyEventStream.listen((_) {
_becomingNoisySubscription = session.becomingNoisyEventStream.listen((_) {
pause();
});
// Broadcast playback state changes
_player.playbackEventStream.listen(_broadcastState);
_playbackEventSubscription = _player.playbackEventStream.listen(_broadcastState);
// Broadcast current media item changes
_player.currentIndexStream.listen((_) {
_currentIndexSubscription = _player.currentIndexStream.listen((_) {
if (_currentMediaItem != null) {
mediaItem.add(_currentMediaItem);
}
@@ -293,4 +300,13 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
Stream<Duration?> get durationStream => _player.durationStream;
MediaItem? get currentMediaItem => _currentMediaItem;
/// Dispose of resources and cancel all subscriptions
Future<void> dispose() async {
await _interruptionSubscription?.cancel();
await _becomingNoisySubscription?.cancel();
await _playbackEventSubscription?.cancel();
await _currentIndexSubscription?.cancel();
await _player.dispose();
}
}
+135
View File
@@ -1,12 +1,20 @@
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;
// Cache size limits to prevent unbounded memory growth
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
static const int _maxSearchCacheSize = 20; // Max search queries cached
static const int _maxPlayerTrackCacheSize = 30; // Max player track associations
// Home screen row caching
List<Album>? _cachedRecentAlbums;
@@ -35,6 +43,7 @@ class CacheService {
// Player track cache (for smooth swipe transitions)
final Map<String, Track?> _playerTrackCache = {};
final Map<String, DateTime> _playerTrackCacheTime = {};
// ============================================================================
// HOME SCREEN ROW CACHING
@@ -57,6 +66,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 +87,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 +108,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)
@@ -126,6 +141,7 @@ class CacheService {
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
_albumTracksCache[cacheKey] = tracks;
_albumTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
}
@@ -152,6 +168,7 @@ class CacheService {
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
_playlistTracksCache[cacheKey] = tracks;
_playlistTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
}
@@ -178,6 +195,7 @@ class CacheService {
void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
_artistAlbumsCache[cacheKey] = albums;
_artistAlbumsCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_artistAlbumsCache, _artistAlbumsCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
}
@@ -202,6 +220,7 @@ class CacheService {
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
_searchCache[cacheKey] = results;
_searchCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_searchCache, _searchCacheTime, _maxSearchCacheSize);
_logger.log('✅ Cached search results for "$cacheKey"');
}
@@ -245,9 +264,20 @@ class CacheService {
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
/// Get all player IDs that have cached tracks
Iterable<String> getAllCachedPlayerIds() => _playerTrackCache.keys;
/// Set cached track for a player
void setCachedTrackForPlayer(String playerId, Track? track) {
_playerTrackCache[playerId] = track;
_playerTrackCacheTime[playerId] = DateTime.now();
_evictOldestEntries(_playerTrackCache, _playerTrackCacheTime, _maxPlayerTrackCacheSize);
}
/// Clear cached track for a player (e.g., when external source is active)
void clearCachedTrackForPlayer(String playerId) {
_playerTrackCache.remove(playerId);
_playerTrackCacheTime.remove(playerId);
}
// ============================================================================
@@ -265,6 +295,7 @@ class CacheService {
_searchCache.clear();
_searchCacheTime.clear();
_playerTrackCache.clear();
_playerTrackCacheTime.clear();
_logger.log('🗑️ All detail caches cleared');
}
@@ -276,4 +307,108 @@ 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');
}
}();
}
// ============================================================================
// LRU CACHE EVICTION
// ============================================================================
/// Evict oldest entries from cache maps to enforce size limit (LRU eviction)
/// Uses the timestamp map to determine which entries are oldest
void _evictOldestEntries<K, V>(
Map<K, V> cache,
Map<K, DateTime> cacheTime,
int maxSize,
) {
if (cache.length <= maxSize) return;
// Sort keys by timestamp (oldest first)
final sortedKeys = cacheTime.keys.toList()
..sort((a, b) => (cacheTime[a] ?? DateTime.now())
.compareTo(cacheTime[b] ?? DateTime.now()));
// Remove oldest entries until we're at maxSize
final keysToRemove = sortedKeys.take(cache.length - maxSize);
for (final key in keysToRemove) {
cache.remove(key);
cacheTime.remove(key);
}
if (keysToRemove.isNotEmpty) {
_logger.log('🗑️ LRU evicted ${keysToRemove.length} cache entries');
}
}
}
+79
View File
@@ -222,4 +222,83 @@ 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();
// ============================================
// Search History Convenience Methods
// ============================================
/// Save a search query to history
Future<void> saveSearchQuery(String query) => db.saveSearchQuery(query);
/// Get recent search queries (up to 10)
Future<List<String>> getRecentSearches() => db.getRecentSearches();
/// Clear all search history
Future<void> clearSearchHistory() => db.clearSearchHistory();
// ============================================
// Cast-to-Sendspin Mapping Methods
// ============================================
static const String _sendspinMappingType = 'cast_sendspin_mapping';
/// Save a Cast UUID to Sendspin ID mapping
Future<void> saveCastToSendspinMapping(String castId, String sendspinId) {
_logger.log('💾 Persisting Cast->Sendspin mapping: $castId -> $sendspinId');
return db.cacheItem(
itemType: _sendspinMappingType,
itemId: castId,
data: jsonEncode({'sendspinId': sendspinId}),
);
}
/// Get persisted Sendspin ID for a Cast UUID
Future<String?> getSendspinIdForCast(String castId) async {
final item = await db.getCachedItem(_sendspinMappingType, castId);
if (item == null) return null;
try {
final data = jsonDecode(item.data) as Map<String, dynamic>;
return data['sendspinId'] as String?;
} catch (e) {
_logger.log('Error decoding Sendspin mapping: $e');
return null;
}
}
/// Load all persisted Cast-to-Sendspin mappings
Future<Map<String, String>> getAllCastToSendspinMappings() async {
final items = await db.getCachedItems(_sendspinMappingType);
final mappings = <String, String>{};
for (final item in items) {
try {
final data = jsonDecode(item.data) as Map<String, dynamic>;
final sendspinId = data['sendspinId'] as String?;
if (sendspinId != null) {
// The itemId is the Cast UUID
mappings[item.itemId] = sendspinId;
}
} catch (e) {
_logger.log('Error decoding Sendspin mapping: $e');
}
}
_logger.log('📦 Loaded ${mappings.length} Cast->Sendspin mappings from database');
return mappings;
}
}
+94 -23
View File
@@ -1,36 +1,99 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
/// Service to generate unique per-installation player IDs
/// Simplified to match KMP client pattern: single storage key, lazy generation
/// This prevents the critical bug where multiple devices of the same model
/// would share the same player ID and trigger playback on all devices
/// Service to generate unique per-device player IDs
/// Uses stable device identifiers (ANDROID_ID) combined with MA username
/// to create persistent player IDs that survive storage wipes and support
/// multiple devices per account
class DeviceIdService {
static const String _keyLocalPlayerId = 'local_player_id';
static final _logger = DebugLogger();
static const _uuid = Uuid();
/// Get or generate a unique player ID for this installation
/// ID is generated once and persisted across app restarts
/// Format: ensemble_<uuid>
///
/// This matches the KMP client pattern: generate on first access, store once
static Future<String> getOrCreateDevicePlayerId() async {
final prefs = await SharedPreferences.getInstance();
// Cache stable device ID to avoid repeated platform calls
static String? _cachedStableDeviceId;
// Single source of truth - no complex migration logic
final existingId = prefs.getString(_keyLocalPlayerId);
if (existingId != null && existingId.startsWith('ensemble_')) {
_logger.log('Using existing player ID: $existingId');
return existingId;
/// Get stable device identifier that persists across storage wipes
/// - Android: Uses ANDROID_ID (persists until factory reset)
/// - iOS: Uses identifierForVendor (persists until app reinstall)
static Future<String> _getStableDeviceId() async {
if (_cachedStableDeviceId != null) {
return _cachedStableDeviceId!;
}
// Generate new ID on first access
final playerId = 'ensemble_${_uuid.v4()}';
await prefs.setString(_keyLocalPlayerId, playerId);
_logger.log('Generated new player ID: $playerId');
return playerId;
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
// ANDROID_ID is unique per device+user combo, persists across storage wipes
// Only resets on factory reset
_cachedStableDeviceId = android.id;
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
// identifierForVendor persists across app updates but resets on reinstall
_cachedStableDeviceId = ios.identifierForVendor ?? 'unknown_ios';
} else {
// Fallback for other platforms (web, desktop)
_cachedStableDeviceId = 'unknown_platform';
}
return _cachedStableDeviceId!;
}
/// Detect if device is a tablet based on screen size
/// Uses the standard 600dp threshold (shortest side >= 600 = tablet)
static bool get isTablet {
try {
final view = ui.PlatformDispatcher.instance.views.first;
final size = view.physicalSize / view.devicePixelRatio;
return size.shortestSide >= 600;
} catch (e) {
_logger.log('Failed to detect device type: $e');
return false; // Default to phone if detection fails
}
}
/// Get or generate a unique player ID for this device
/// Format: ensemble_{username}_{deviceId} or ensemble_{deviceId}
///
/// Key behaviors:
/// - Uses stable device ID that survives storage wipes
/// - Includes MA username to support multiple devices per account
/// - Regenerates if username changes (user logged in with different account)
static Future<String> getOrCreateDevicePlayerId() async {
final prefs = await SharedPreferences.getInstance();
final stableDeviceId = await _getStableDeviceId();
final maUsername = await SettingsService.getUsername();
// Generate expected ID based on current username and device
// Use shorter device ID suffix (first 8 chars) to keep ID reasonable length
final deviceIdSuffix = stableDeviceId.length > 8
? stableDeviceId.substring(0, 8)
: stableDeviceId;
final expectedId = maUsername != null && maUsername.isNotEmpty
? 'ensemble_${maUsername}_$deviceIdSuffix'
: 'ensemble_$deviceIdSuffix';
// Check if we already have the correct ID
final existingId = prefs.getString(_keyLocalPlayerId);
if (existingId == expectedId) {
_logger.log('Using existing player ID: $expectedId');
return expectedId;
}
// ID needs to be updated (first login, username changed, or migration)
if (existingId != null) {
_logger.log('Updating player ID from $existingId to $expectedId');
} else {
_logger.log('Generated new player ID: $expectedId');
}
await prefs.setString(_keyLocalPlayerId, expectedId);
return expectedId;
}
/// Adopt an existing player ID (used when claiming a ghost player)
@@ -45,4 +108,12 @@ class DeviceIdService {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLocalPlayerId) == null;
}
/// Force regeneration of player ID on next call
/// Useful after login/logout to ensure ID matches current user
static Future<void> invalidateCachedId() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyLocalPlayerId);
_logger.log('Invalidated cached player ID');
}
}
@@ -82,5 +82,9 @@ class HardwareVolumeService {
} catch (e) {
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
}
// Close StreamControllers to prevent memory leaks
await _volumeUpController.close();
await _volumeDownController.close();
}
}
+88 -5
View File
@@ -1399,7 +1399,7 @@ class MusicAssistantAPI {
final result = searchResponse['result'] as Map<String, dynamic>?;
if (result == null) {
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
// Parse results from the search response
@@ -1418,6 +1418,16 @@ class MusicAssistantAPI {
.toList() ??
[];
final playlists = (result['playlists'] as List<dynamic>?)
?.map((item) => Playlist.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
final audiobooks = (result['audiobooks'] as List<dynamic>?)
?.map((item) => Audiobook.fromJson(item as Map<String, dynamic>))
.toList() ??
[];
// Deduplicate results by name
// MA returns both library items and provider items for the same content
// Prefer library items (provider='library') as they have all provider mappings
@@ -1425,15 +1435,17 @@ class MusicAssistantAPI {
'artists': _deduplicateResults(artists),
'albums': _deduplicateResults(albums),
'tracks': _deduplicateResults(tracks),
'playlists': _deduplicateResults(playlists),
'audiobooks': _deduplicateResults(audiobooks),
};
} catch (e) {
_logger.log('Error searching: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
}
},
).catchError((e) {
_logger.log('Error searching after retries: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []};
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
});
}
@@ -1516,6 +1528,7 @@ class MusicAssistantAPI {
currentIndex = queueResult['current_index'] as int?;
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
repeatMode = queueResult['repeat_mode'] as String?;
_logger.log('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
final currentItemName = currentItemData?['name'] as String?;
@@ -1602,6 +1615,25 @@ class MusicAssistantAPI {
}
}
/// Add a single track to the queue without starting playback
Future<void> addTrackToQueue(String playerId, Track track) async {
try {
final uri = _buildTrackUri(track);
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': [uri],
'option': 'add', // Add to queue without starting
},
);
} catch (e) {
_logger.log('Error adding track to queue: $e');
rethrow;
}
}
/// Play multiple tracks via queue
/// If clearQueue is true, replaces the queue (default behavior)
/// If startIndex is provided, only tracks from that index onwards will be queued
@@ -1996,12 +2028,51 @@ 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 {
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
try {
await _sendCommand(
'player_queues/shuffle',
args: {'queue_id': queueId},
args: {'queue_id': queueId, 'shuffle_enabled': shuffleEnabled},
);
} catch (e) {
_logger.log('Error toggling shuffle: $e');
@@ -2469,6 +2540,7 @@ class MusicAssistantAPI {
}
}
} catch (e) {
_logger.log('⚠️ Error parsing stream URI "$uri": $e');
}
}
@@ -2837,6 +2909,17 @@ class MusicAssistantAPI {
_updateConnectionState(MAConnectionState.disconnected);
await _channel?.sink.close();
_channel = null;
// Complete all pending requests with an error before clearing
_cancelPendingRequests('Connection disconnected');
}
/// Cancel all pending requests with an error message
void _cancelPendingRequests(String reason) {
for (final entry in _pendingRequests.entries) {
if (!entry.value.isCompleted) {
entry.value.completeError(Exception(reason));
}
}
_pendingRequests.clear();
}
+19 -4
View File
@@ -83,6 +83,11 @@ class PcmAudioPlayer {
// Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response
static const int _feedThreshold = 5000;
// Maximum buffer size to prevent memory overflow (~10 seconds of audio)
// At 48kHz stereo 16-bit: 192KB/sec, so ~2MB for 10 seconds
// Each chunk is ~4KB, so max ~500 chunks
static const int _maxBufferChunks = 500;
// Stats
int _framesPlayed = 0;
int _bytesPlayed = 0;
@@ -201,6 +206,16 @@ class PcmAudioPlayer {
return true;
}
/// Safely add audio data to buffer with overflow protection
void _addToBuffer(Uint8List audioData) {
// If buffer is full, drop oldest chunks to make room
while (_audioBuffer.length >= _maxBufferChunks) {
_audioBuffer.removeAt(0);
_logger.log('⚠️ PcmAudioPlayer: Buffer overflow - dropping oldest chunk');
}
_audioBuffer.add(audioData);
}
/// Handle incoming audio data from the stream
void _onAudioData(Uint8List audioData) {
// Ignore data if in error state
@@ -215,7 +230,7 @@ class PcmAudioPlayer {
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
_isAutoRecovering = true;
_audioBuffer.add(audioData);
_addToBuffer(audioData);
// Trigger async recovery
_autoRecoverFromPause();
@@ -224,7 +239,7 @@ class PcmAudioPlayer {
// If already recovering or in transitional state, buffer the data
if (_isAutoRecovering) {
_audioBuffer.add(audioData);
_addToBuffer(audioData);
return;
}
@@ -232,8 +247,8 @@ class PcmAudioPlayer {
return;
}
// Add to buffer
_audioBuffer.add(audioData);
// Add to buffer with overflow protection
_addToBuffer(audioData);
// Start playback if not already started
if (!_isStarted && _state == PcmPlayerState.ready) {
+17 -2
View File
@@ -24,6 +24,10 @@ class PositionTracker {
double _anchorPosition = 0.0; // Position in seconds at anchor time
DateTime _anchorTime = DateTime.now(); // When we set the anchor
// Maximum time to interpolate from a stale anchor before capping
// After this, we stop adding time to prevent indefinite drift
static const Duration _maxAnchorAge = Duration(seconds: 30);
// Interpolation timer
Timer? _interpolationTimer;
@@ -43,7 +47,13 @@ class PositionTracker {
}
final now = DateTime.now();
final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0;
final anchorAge = now.difference(_anchorTime);
// Cap interpolation at max anchor age to prevent indefinite drift
// If anchor is stale, return last known position (anchor + max age)
final elapsed = anchorAge > _maxAnchorAge
? _maxAnchorAge.inMilliseconds / 1000.0
: anchorAge.inMilliseconds / 1000.0;
final interpolated = _anchorPosition + elapsed;
// Cap at duration to prevent overflow
@@ -61,7 +71,12 @@ class PositionTracker {
}
final now = DateTime.now();
final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0;
final anchorAge = now.difference(_anchorTime);
// Cap interpolation at max anchor age to prevent indefinite drift
final elapsed = anchorAge > _maxAnchorAge
? _maxAnchorAge.inMilliseconds / 1000.0
: anchorAge.inMilliseconds / 1000.0;
final interpolated = _anchorPosition + elapsed;
// Cap at duration
+8 -2
View File
@@ -151,7 +151,10 @@ class RecentlyPlayedService {
if (item.metadata != null) {
try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
} catch (_) {}
} catch (e) {
// Corrupted metadata JSON - log and continue with null metadata
_logger.log('⚠️ Error parsing album metadata for ${item.mediaId}: $e');
}
}
// Build metadata with images if available (for instant artwork display)
@@ -203,7 +206,10 @@ class RecentlyPlayedService {
if (item.metadata != null) {
try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
} catch (_) {}
} catch (e) {
// Corrupted metadata JSON - log and continue with null metadata
_logger.log('⚠️ Error parsing audiobook metadata for ${item.mediaId}: $e');
}
}
audiobooks.add(Audiobook(
+148
View File
@@ -0,0 +1,148 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
/// Service for securely storing sensitive data like passwords and tokens.
/// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android).
class SecureStorageService {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
);
// Keys for secure storage
static const String _keyAuthToken = 'secure_auth_token';
static const String _keyMaAuthToken = 'secure_ma_auth_token';
static const String _keyAuthCredentials = 'secure_auth_credentials';
static const String _keyPassword = 'secure_password';
static const String _keyAbsApiToken = 'secure_abs_api_token';
// Auth Token (for stream requests)
static Future<String?> getAuthToken() async {
return await _storage.read(key: _keyAuthToken);
}
static Future<void> setAuthToken(String? token) async {
if (token == null || token.isEmpty) {
await _storage.delete(key: _keyAuthToken);
} else {
await _storage.write(key: _keyAuthToken, value: token);
}
}
// Music Assistant Native Auth Token (long-lived token)
static Future<String?> getMaAuthToken() async {
return await _storage.read(key: _keyMaAuthToken);
}
static Future<void> setMaAuthToken(String? token) async {
if (token == null || token.isEmpty) {
await _storage.delete(key: _keyMaAuthToken);
} else {
await _storage.write(key: _keyMaAuthToken, value: token);
}
}
static Future<void> clearMaAuthToken() async {
await _storage.delete(key: _keyMaAuthToken);
}
// Auth Credentials (serialized auth strategy credentials)
static Future<Map<String, dynamic>?> getAuthCredentials() async {
final json = await _storage.read(key: _keyAuthCredentials);
if (json == null) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (e) {
return null;
}
}
static Future<void> setAuthCredentials(Map<String, dynamic> credentials) async {
await _storage.write(key: _keyAuthCredentials, value: jsonEncode(credentials));
}
static Future<void> clearAuthCredentials() async {
await _storage.delete(key: _keyAuthCredentials);
}
// Password
static Future<String?> getPassword() async {
return await _storage.read(key: _keyPassword);
}
static Future<void> setPassword(String? password) async {
if (password == null || password.isEmpty) {
await _storage.delete(key: _keyPassword);
} else {
await _storage.write(key: _keyPassword, value: password);
}
}
// Audiobookshelf API Token
static Future<String?> getAbsApiToken() async {
return await _storage.read(key: _keyAbsApiToken);
}
static Future<void> setAbsApiToken(String? token) async {
if (token == null || token.isEmpty) {
await _storage.delete(key: _keyAbsApiToken);
} else {
await _storage.write(key: _keyAbsApiToken, value: token);
}
}
/// Clear all secure storage (used during logout)
static Future<void> clearAll() async {
await _storage.deleteAll();
}
/// Migrate credentials from SharedPreferences to secure storage.
/// Call this once during app upgrade to migrate existing users.
static Future<void> migrateFromSharedPreferences() async {
// Import SharedPreferences for migration
final SharedPreferences prefs = await SharedPreferences.getInstance();
// Migrate auth token
final authToken = prefs.getString('auth_token');
if (authToken != null && authToken.isNotEmpty) {
await setAuthToken(authToken);
await prefs.remove('auth_token');
}
// Migrate MA auth token
final maAuthToken = prefs.getString('ma_auth_token');
if (maAuthToken != null && maAuthToken.isNotEmpty) {
await setMaAuthToken(maAuthToken);
await prefs.remove('ma_auth_token');
}
// Migrate auth credentials
final authCredentials = prefs.getString('auth_credentials');
if (authCredentials != null && authCredentials.isNotEmpty) {
try {
final decoded = jsonDecode(authCredentials) as Map<String, dynamic>;
await setAuthCredentials(decoded);
await prefs.remove('auth_credentials');
} catch (_) {
// Invalid JSON, just remove it
await prefs.remove('auth_credentials');
}
}
// Migrate password
final password = prefs.getString('password');
if (password != null && password.isNotEmpty) {
await setPassword(password);
await prefs.remove('password');
}
// Migrate ABS API token
final absApiToken = prefs.getString('abs_api_token');
if (absApiToken != null && absApiToken.isNotEmpty) {
await setAbsApiToken(absApiToken);
await prefs.remove('abs_api_token');
}
}
}
+33 -3
View File
@@ -87,6 +87,9 @@ class SendspinService {
// Auth token for proxy authentication (MA 2.7.1+)
String? _authToken;
// Connection deduplication guard - prevents multiple concurrent auth attempts
Completer<bool>? _connectionInProgress;
SendspinService(this.serverUrl);
/// Set the MA auth token for proxy authentication
@@ -100,6 +103,13 @@ class SendspinService {
if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true;
// Deduplicate concurrent connection attempts
if (_connectionInProgress != null) {
_logger.log('Sendspin: Connection already in progress, waiting...');
return _connectionInProgress!.future;
}
_connectionInProgress = Completer<bool>();
_updateState(SendspinConnectionState.connecting);
try {
@@ -126,13 +136,19 @@ class SendspinService {
if (!connected) {
_logger.log('Sendspin: External proxy connection failed');
_updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false;
}
_connectionInProgress?.complete(true);
_connectionInProgress = null;
return true;
} catch (e) {
_logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false;
}
}
@@ -142,6 +158,13 @@ class SendspinService {
if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true;
// Deduplicate concurrent connection attempts
if (_connectionInProgress != null) {
_logger.log('Sendspin: Connection already in progress, waiting...');
return _connectionInProgress!.future;
}
_connectionInProgress = Completer<bool>();
_updateState(SendspinConnectionState.connecting);
try {
@@ -154,13 +177,19 @@ class SendspinService {
if (!connected) {
_updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false;
}
_connectionInProgress?.complete(true);
_connectionInProgress = null;
return true;
} catch (e) {
_logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false;
}
}
@@ -518,8 +547,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 +610,7 @@ class SendspinService {
_sendMessage({
'type': 'client/time',
'payload': {
'timestamp': timestampMicroseconds,
'client_transmitted': timestampMicroseconds,
},
}, allowDuringHandshake: allowDuringHandshake);
}
+78 -54
View File
@@ -1,5 +1,7 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'device_id_service.dart';
import 'secure_storage_service.dart';
class SettingsService {
static const String _keyServerUrl = 'server_url';
@@ -74,6 +76,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);
@@ -121,66 +128,44 @@ class SettingsService {
}
}
// Get authentication token for stream requests
// Get authentication token for stream requests (securely stored)
static Future<String?> getAuthToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyAuthToken);
return await SecureStorageService.getAuthToken();
}
// Set authentication token for stream requests
// Set authentication token for stream requests (securely stored)
static Future<void> setAuthToken(String? token) async {
final prefs = await SharedPreferences.getInstance();
if (token == null || token.isEmpty) {
await prefs.remove(_keyAuthToken);
} else {
await prefs.setString(_keyAuthToken, token);
}
await SecureStorageService.setAuthToken(token);
}
// Get Music Assistant native auth token (long-lived token)
// Get Music Assistant native auth token (long-lived token, securely stored)
static Future<String?> getMaAuthToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyMaAuthToken);
return await SecureStorageService.getMaAuthToken();
}
// Set Music Assistant native auth token (long-lived token)
// Set Music Assistant native auth token (long-lived token, securely stored)
static Future<void> setMaAuthToken(String? token) async {
final prefs = await SharedPreferences.getInstance();
if (token == null || token.isEmpty) {
await prefs.remove(_keyMaAuthToken);
} else {
await prefs.setString(_keyMaAuthToken, token);
}
await SecureStorageService.setMaAuthToken(token);
}
// Clear Music Assistant native auth token
static Future<void> clearMaAuthToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyMaAuthToken);
await SecureStorageService.clearMaAuthToken();
}
// Get authentication credentials (serialized auth strategy credentials)
// Get authentication credentials (serialized auth strategy credentials, securely stored)
static Future<Map<String, dynamic>?> getAuthCredentials() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_keyAuthCredentials);
if (json == null) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (e) {
return null;
}
return await SecureStorageService.getAuthCredentials();
}
// Set authentication credentials (serialized auth strategy credentials)
// Set authentication credentials (serialized auth strategy credentials, securely stored)
static Future<void> setAuthCredentials(Map<String, dynamic> credentials) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAuthCredentials, jsonEncode(credentials));
await SecureStorageService.setAuthCredentials(credentials);
}
// Clear authentication credentials
static Future<void> clearAuthCredentials() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyAuthCredentials);
await SecureStorageService.clearAuthCredentials();
}
// Get username for authentication
@@ -199,20 +184,14 @@ class SettingsService {
}
}
// Get password for authentication
// Get password for authentication (securely stored)
static Future<String?> getPassword() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyPassword);
return await SecureStorageService.getPassword();
}
// Set password for authentication
// Set password for authentication (securely stored)
static Future<void> setPassword(String? password) async {
final prefs = await SharedPreferences.getInstance();
if (password == null || password.isEmpty) {
await prefs.remove(_keyPassword);
} else {
await prefs.setString(_keyPassword, password);
}
await SecureStorageService.setPassword(password);
}
// Get built-in player ID (persistent UUID for this device)
@@ -389,18 +368,27 @@ class SettingsService {
}
// Helper to create player name with possessive apostrophe
// Automatically detects Phone vs Tablet based on screen size
static String _makePlayerName(String ownerName) {
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
final deviceType = DeviceIdService.isTablet ? 'Tablet' : 'Phone';
// Handle possessive: "Chris" -> "Chris' Tablet", "Mom" -> "Mom's Phone"
if (ownerName.toLowerCase().endsWith('s')) {
return "$ownerName' Phone";
return "$ownerName' $deviceType";
} else {
return "$ownerName's Phone";
return "$ownerName's $deviceType";
}
}
static Future<void> clearSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
await SecureStorageService.clearAll(); // Also clear secure storage
}
/// Migrate credentials from old SharedPreferences storage to secure storage.
/// Should be called once during app startup for existing users.
static Future<void> migrateToSecureStorage() async {
await SecureStorageService.migrateFromSharedPreferences();
}
// Home Screen Row Settings (Main rows - default on)
@@ -666,13 +654,11 @@ class SettingsService {
}
static Future<String?> getAbsApiToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyAbsApiToken);
return await SecureStorageService.getAbsApiToken();
}
static Future<void> setAbsApiToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyAbsApiToken, token);
await SecureStorageService.setAbsApiToken(token);
}
static Future<bool> getAbsEnabled() async {
@@ -688,8 +674,8 @@ class SettingsService {
static Future<void> clearAbsSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyAbsServerUrl);
await prefs.remove(_keyAbsApiToken);
await prefs.remove(_keyAbsEnabled);
await SecureStorageService.setAbsApiToken(null); // Clear from secure storage
}
// Audiobookshelf Library Settings (via MA browse API)
@@ -762,4 +748,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);
}
}
+321 -29
View File
@@ -32,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
@@ -67,13 +77,16 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
// Dimensions
static const double _collapsedHeight = 64.0;
static double get _collapsedHeight => MiniPlayerLayout.height;
static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar)
static const double _collapsedBorderRadius = 16.0;
static const double _collapsedArtSize = 64.0;
static double get _collapsedArtSize => MiniPlayerLayout.artSize;
static const double _bottomNavHeight = 56.0;
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
// Pastel yellow for grouped players (matches PlayerCard.groupBorderColor)
static const Color _groupBorderColor = Color(0xFFFFF59D);
// Track horizontal drag start position
double? _horizontalDragStartX;
@@ -91,6 +104,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// When true, we hide main content and show peek content at center
bool _inTransition = false;
// Volume swipe state (only active when device reveal is visible)
bool _isDraggingVolume = false;
double _dragVolumeLevel = 0.0;
int _lastVolumeUpdateTime = 0;
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
// Track favorite state for current track
bool _isCurrentTrackFavorite = false;
String? _lastTrackUri; // Track which track we last checked favorite status for
@@ -332,14 +351,17 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
// 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 {
setState(() => _isLoadingQueue = true);
// Only load from cache if we don't already have a queue (preserves optimistic updates)
if (_queue == null) {
final cachedQueue = await maProvider.getCachedQueue(player.playerId);
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
setState(() {
_queue = cachedQueue;
_isLoadingQueue = false;
});
} else {
setState(() => _isLoadingQueue = true);
}
}
// 2. Fetch fresh queue in background
@@ -347,11 +369,28 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
try {
final freshQueue = await maProvider.getQueue(player.playerId);
if (mounted && freshQueue != null) {
// Only update if queue changed
debugPrint('🔀 Queue loaded: shuffle=${freshQueue.shuffle}, shuffleEnabled=${freshQueue.shuffleEnabled}');
// Check if queue items were reordered by comparing first few item IDs
bool itemsReordered = false;
if (_queue != null && _queue!.items.isNotEmpty && freshQueue.items.isNotEmpty) {
// Compare first 3 items to detect reordering (shuffle changes item order)
final maxCheck = (_queue!.items.length < 3 ? _queue!.items.length : 3).clamp(1, freshQueue.items.length);
for (int i = 0; i < maxCheck; i++) {
if (_queue!.items[i].queueItemId != freshQueue.items[i].queueItemId) {
itemsReordered = true;
debugPrint('🔀 Queue items reordered detected at position $i');
break;
}
}
}
// Update if queue metadata or item order changed
final queueChanged = _queue == null ||
_queue!.items.length != freshQueue.items.length ||
_queue!.currentIndex != freshQueue.currentIndex;
_queue!.currentIndex != freshQueue.currentIndex ||
_queue!.shuffle != freshQueue.shuffle ||
itemsReordered;
if (queueChanged) {
debugPrint('🔀 Queue changed, updating UI (reordered=$itemsReordered)');
setState(() {
_queue = freshQueue;
_isLoadingQueue = false;
@@ -393,16 +432,69 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}
Future<void> _toggleShuffle() async {
if (_queue == null) return;
debugPrint('🔀 Shuffle button pressed');
if (_queue == null) {
debugPrint('🔀 Shuffle: _queue is null, returning');
return;
}
final newShuffleState = !_queue!.shuffle;
debugPrint('🔀 Shuffle: current=${_queue!.shuffle}, toggling to $newShuffleState');
// Optimistically update local state for immediate visual feedback
setState(() {
_queue = PlayerQueue(
playerId: _queue!.playerId,
items: _queue!.items,
currentIndex: _queue!.currentIndex,
shuffleEnabled: newShuffleState,
repeatMode: _queue!.repeatMode,
);
});
debugPrint('🔀 Shuffle: optimistic update applied, shuffleEnabled=$newShuffleState');
final maProvider = context.read<MusicAssistantProvider>();
await maProvider.toggleShuffle(_queue!.playerId);
// Toggle: if currently shuffled, disable; if not shuffled, enable
await maProvider.toggleShuffle(_queue!.playerId, newShuffleState);
debugPrint('🔀 Shuffle: command sent, waiting for server to reorder queue');
// Small delay to allow server to finish reordering queue items
await Future.delayed(const Duration(milliseconds: 150));
debugPrint('🔀 Shuffle: reloading queue');
await _loadQueue();
}
Future<void> _cycleRepeat() async {
if (_queue == null) return;
// Calculate next repeat mode: off -> all -> one -> off
String nextMode;
switch (_queue!.repeatMode) {
case 'off':
case null:
nextMode = 'all';
break;
case 'all':
nextMode = 'one';
break;
case 'one':
nextMode = 'off';
break;
default:
nextMode = 'off';
}
// Optimistically update local state for immediate visual feedback
setState(() {
_queue = PlayerQueue(
playerId: _queue!.playerId,
items: _queue!.items,
currentIndex: _queue!.currentIndex,
shuffleEnabled: _queue!.shuffleEnabled,
repeatMode: nextMode,
);
});
final maProvider = context.read<MusicAssistantProvider>();
await maProvider.cycleRepeatMode(_queue!.playerId, _queue!.repeatMode);
await maProvider.setRepeatMode(_queue!.playerId, nextMode);
await _loadQueue();
}
@@ -1009,6 +1101,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
right: _collapsedMargin,
bottom: adjustedBottomOffset,
child: GestureDetector(
// Tap to dismiss player reveal (matching _buildMorphingPlayer behavior)
onTap: widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : null,
onVerticalDragUpdate: (details) {
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
widget.onRevealPlayers!();
@@ -1150,7 +1244,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedArtLeft = (screenSize.width - expandedArtSize) / 2;
final artLeft = _lerpDouble(collapsedArtLeft, expandedArtLeft, t);
final collapsedArtTop = 0.0;
final collapsedArtTop = (_collapsedHeight - _collapsedArtSize) / 2; // Center vertically
final expandedArtTop = topPadding + headerHeight + 16;
final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t);
@@ -1226,6 +1320,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
// Player name - only visible when collapsed (third line)
final playerNameTop = MiniPlayerLayout.tertiaryTop;
// Album - subtle, below artist
final expandedAlbumTop = expandedArtistTop + artistHeight + artistToAlbumGap;
@@ -1264,8 +1361,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: GestureDetector(
// Use translucent to allow child widgets (like buttons) to receive taps
behavior: HitTestBehavior.translucent,
// Only handle tap when collapsed - when expanded, let children handle their own taps
onTap: isExpanded ? null : expand,
// Handle tap: when device list is visible, dismiss it; when collapsed, expand
onTap: isExpanded ? null : (widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : expand),
onVerticalDragStart: (details) {
// For expanded player or queue panel: start tracking immediately
// For collapsed player: defer decision until we know swipe direction
@@ -1309,9 +1406,39 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
},
onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx;
// Start volume drag if device reveal is visible
if (widget.isDeviceRevealVisible && !isExpanded) {
final currentVolume = (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0;
setState(() {
_isDraggingVolume = true;
_dragVolumeLevel = currentVolume;
});
HapticFeedback.lightImpact();
}
},
onHorizontalDragUpdate: (details) {
// Only handle in collapsed mode with multiple players
// Volume swipe when device reveal is visible
if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) {
// Directional volume: drag right = increase, drag left = decrease
// Multiple swipes accumulate - each starts from current volume
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / collapsedWidth;
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
setState(() {
_dragVolumeLevel = newVolume;
});
// Throttle API calls to prevent flooding
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastVolumeUpdateTime >= _volumeThrottleMs) {
_lastVolumeUpdateTime = now;
maProvider.setVolume(selectedPlayer.playerId, (newVolume * 100).round());
}
}
return;
}
// Only handle player swipe in collapsed mode with multiple players
if (isExpanded || !hasMultiplePlayers) return;
// Ignore drags that started in the edge dead zone
@@ -1324,6 +1451,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragUpdate(details, maProvider, collapsedWidth);
},
onHorizontalDragEnd: (details) {
// End volume drag
if (_isDraggingVolume) {
// Send final volume on release
maProvider.setVolume(selectedPlayer.playerId, (_dragVolumeLevel * 100).round());
setState(() {
_isDraggingVolume = false;
});
HapticFeedback.lightImpact();
_horizontalDragStartX = null;
return;
}
// Ignore swipes that started near the edges (Android back gesture zone)
final screenWidth = MediaQuery.of(context).size.width;
final startedInDeadZone = _horizontalDragStartX != null &&
@@ -1347,13 +1486,22 @@ 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(
// Use foregroundDecoration for border so it renders ON TOP of content
// This prevents the album art from clipping the yellow synced border
foregroundDecoration: maProvider.isPlayerManuallySynced(selectedPlayer.playerId) && t < 0.5
? BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
border: 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(
@@ -1468,10 +1616,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,
@@ -1495,17 +1681,20 @@ 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,
@@ -1522,6 +1711,26 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
),
// Player name - third line, only visible when collapsed
// Same size as artist, same color as track
if (t < 0.5 && !(_inTransition && t < 0.1 && _peekPlayer != null) && !(widget.isHintVisible && t < 0.5))
Positioned(
left: titleLeft + miniPlayerSlideOffset,
top: playerNameTop,
child: SizedBox(
width: titleWidth,
child: Text(
selectedPlayer.name,
style: TextStyle(
color: textColor.withOpacity(1.0 - t * 2), // Fade out as expanding
fontSize: MiniPlayerLayout.tertiaryFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
// Album name OR Chapter name (expanded only)
// GPU PERF: Use color alpha instead of Opacity widget
// For audiobooks: show current chapter; for music: show album
@@ -1691,7 +1900,60 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
t: t,
expandedElementsOpacity: expandedElementsOpacity,
)
: Row(
// When device reveal is visible (player list shown), use compact controls
// like PlayerCard: Play, Next, Power - matching other players in the list
: widget.isDeviceRevealVisible && t < 0.5
? Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Play/Pause - compact like PlayerCard
// Touch target increased to 44dp for accessibility
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 44,
height: 44,
child: IconButton(
icon: Icon(
selectedPlayer.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor,
size: 28,
),
onPressed: () => maProvider.playPauseSelectedPlayer(),
padding: EdgeInsets.zero,
),
),
),
// Skip next - nudged right like PlayerCard
Transform.translate(
offset: const Offset(6, 0),
child: IconButton(
icon: Icon(
Icons.skip_next_rounded,
color: textColor,
size: 28,
),
onPressed: () => maProvider.nextTrackSelectedPlayer(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
// Power button - smallest, like PlayerCard
IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: selectedPlayer.powered ? textColor : textColor.withOpacity(0.5),
size: 20,
),
onPressed: () => maProvider.togglePower(selectedPlayer.playerId),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [
@@ -1882,10 +2144,37 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
),
),
// Volume swipe overlay (covers entire mini player when dragging volume)
// Only visible when device reveal is open and user is dragging
// Positioned last in Stack so it renders ON TOP of all content
if (_isDraggingVolume && t < 0.5)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: Stack(
children: [
// Unfilled (darker) background
Container(color: collapsedBgUnplayed),
// Filled (lighter) portion based on volume
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
heightFactor: 1.0,
child: Container(color: collapsedBg),
),
),
],
),
),
),
],
),
),
),
),
),
);
}
@@ -1918,12 +2207,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
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;
// Show player name as third line only when playing
final peekPlayerName = hasTrack ? (peekPlayer?.name ?? '') : null;
return Transform.translate(
offset: Offset(peekBaseOffset, 0),
child: MiniPlayerContent(
primaryText: peekTrackName,
secondaryText: peekArtistName,
tertiaryText: peekPlayerName,
imageUrl: hasTrack ? peekImageUrl : null,
playerName: peekPlayer?.name ?? '',
backgroundColor: backgroundColor,
+379 -52
View File
@@ -1,11 +1,15 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import 'expandable_player.dart';
import 'player/mini_player_content.dart' show MiniPlayerLayout;
import 'player/player_reveal_overlay.dart';
/// Cached color with contrast adjustment
@@ -53,14 +57,14 @@ class BottomSpacing {
/// Height of the bottom navigation bar
static const double navBarHeight = 56.0;
/// Height of mini player when visible (64px height + 12px margin)
static const double miniPlayerHeight = 76.0;
/// Height of mini player when visible (height + 12px margin)
static double get miniPlayerHeight => MiniPlayerLayout.height + 12.0;
/// Space needed when only nav bar is visible (with some extra padding)
static const double navBarOnly = navBarHeight + 16.0;
/// Space needed when mini player is also visible
static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 22.0;
static double get withMiniPlayer => navBarHeight + miniPlayerHeight + 22.0;
}
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
@@ -129,7 +133,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,17 +148,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>();
@@ -171,30 +198,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();
}
@@ -212,11 +343,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() {
@@ -224,6 +373,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
_bounceOffsetNotifier.value = 0;
setState(() {
_isRevealVisible = false;
_isOnboardingReveal = false;
});
}
@@ -231,14 +381,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
@@ -246,12 +409,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(
@@ -263,14 +460,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
@@ -359,9 +560,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(
@@ -369,33 +686,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,
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,
),
);
}
+73 -13
View File
@@ -3,18 +3,24 @@ 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 height = 72.0;
static const double artSize = 72.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 textLeft = artSize + textLeftOffset; // 82px
// 3-line layout: track, artist, player
static const double primaryTop = 7.0;
static const double secondaryTop = 27.0;
static const double tertiaryTop = 46.0;
// 2-line layout (centered): player name, hint
static const double primaryTop2Line = 16.0;
static const double secondaryTop2Line = 36.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 double tertiaryFontSize = 14.0;
static const FontWeight primaryFontWeight = FontWeight.w500;
}
@@ -30,6 +36,10 @@ class MiniPlayerContent extends StatelessWidget {
/// If null, primary text will be vertically centered
final String? secondaryText;
/// Tertiary text line (player name when playing)
/// If provided, uses 3-line layout; if null, uses centered 2-line layout
final String? tertiaryText;
/// Album art URL - if null, shows device icon
final String? imageUrl;
@@ -48,6 +58,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;
@@ -58,12 +71,14 @@ class MiniPlayerContent extends StatelessWidget {
super.key,
required this.primaryText,
this.secondaryText,
this.tertiaryText,
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,
});
@@ -71,8 +86,13 @@ class MiniPlayerContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
final slidePixels = slideOffset * width;
// Use 3-line layout when tertiary exists, 2-line centered otherwise
final primaryTop = hasTertiaryLine ? MiniPlayerLayout.primaryTop : MiniPlayerLayout.primaryTop2Line;
final secondaryTop = hasTertiaryLine ? MiniPlayerLayout.secondaryTop : MiniPlayerLayout.secondaryTop2Line;
// Calculate text width (leaves room for right padding)
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
@@ -95,10 +115,10 @@ class MiniPlayerContent extends StatelessWidget {
child: Container(color: backgroundColor),
),
// Art / Icon area
// Art / Icon area - centered vertically
Positioned(
left: slidePixels,
top: 0,
top: (MiniPlayerLayout.height - MiniPlayerLayout.artSize) / 2,
child: SizedBox(
width: MiniPlayerLayout.artSize,
height: MiniPlayerLayout.artSize,
@@ -121,7 +141,7 @@ class MiniPlayerContent extends StatelessWidget {
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: hasSecondaryLine
? MiniPlayerLayout.primaryTop
? primaryTop
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
right: MiniPlayerLayout.textRightPadding - slidePixels,
child: Text(
@@ -136,17 +156,57 @@ class MiniPlayerContent extends StatelessWidget {
),
),
// Secondary text line
// Secondary text line (with optional hint icon)
if (hasSecondaryLine)
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: MiniPlayerLayout.secondaryTop,
top: 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,
),
),
// Tertiary text line (player name, only for 3-line layout)
if (hasTertiaryLine)
Positioned(
left: MiniPlayerLayout.textLeft + slidePixels,
top: MiniPlayerLayout.tertiaryTop,
right: MiniPlayerLayout.textRightPadding - slidePixels,
child: Text(
secondaryText!,
tertiaryText!,
style: TextStyle(
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
fontSize: MiniPlayerLayout.secondaryFontSize,
color: textColor,
fontSize: MiniPlayerLayout.tertiaryFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
+280 -135
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart';
@@ -6,18 +7,25 @@ import '../../l10n/app_localizations.dart';
/// A player card that matches the mini player's visual style.
/// Used in the player reveal overlay when swiping down or tapping the device button.
class PlayerCard extends StatelessWidget {
/// Supports horizontal swipe to adjust volume with a two-tone overlay.
class PlayerCard extends StatefulWidget {
final dynamic player;
final dynamic trackInfo;
final String? albumArtUrl;
final bool isSelected;
final bool isPlaying;
final bool isGrouped;
final Color backgroundColor;
final Color textColor;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final VoidCallback? onPlayPause;
final VoidCallback? onSkipNext;
final VoidCallback? onPower;
final ValueChanged<double>? onVolumeChange;
// Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D);
const PlayerCard({
super.key,
@@ -26,176 +34,313 @@ 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,
this.onVolumeChange,
});
@override
State<PlayerCard> createState() => _PlayerCardState();
}
class _PlayerCardState extends State<PlayerCard> {
bool _isDraggingVolume = false;
double _dragVolumeLevel = 0.0;
double _cardWidth = 0.0;
int _lastVolumeUpdateTime = 0;
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
@override
Widget build(BuildContext context) {
// Match mini player dimensions
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
const double cardHeight = Dimensions.miniPlayerHeight;
const double artSize = cardHeight;
const double borderRadius = Radii.xl; // 16px - same as mini player
return GestureDetector(
onTap: onTap,
child: Container(
height: cardHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
// Album art or speaker icon - same size for consistent text alignment
SizedBox(
width: artSize,
height: artSize,
child: albumArtUrl != null
? CachedNetworkImage(
imageUrl: albumArtUrl!,
fit: BoxFit.cover,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildSpeakerIcon(),
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
)
: _buildSpeakerIcon(),
),
// Colors for volume overlay (same as mini player progress bar)
final filledColor = widget.backgroundColor;
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
// Player info
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Player name
Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getStatusColor(),
),
),
Expanded(
child: Text(
player.name,
style: TextStyle(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 2),
// Track name or status
Text(
_getSubtitle(context),
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
return LayoutBuilder(
builder: (context, constraints) {
_cardWidth = constraints.maxWidth;
return GestureDetector(
onTap: _isDraggingVolume ? null : widget.onTap,
onLongPress: _isDraggingVolume ? null : widget.onLongPress,
onHorizontalDragStart: _onDragStart,
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
onHorizontalDragCancel: _onDragCancel,
// Use Stack to render border ON TOP of content, preventing album art clipping
child: Stack(
children: [
// Main card content (hidden when dragging volume)
if (!_isDraggingVolume)
_buildCardContent(cardHeight, artSize, borderRadius),
// Volume overlay (shown when dragging)
if (_isDraggingVolume)
Container(
height: cardHeight,
decoration: BoxDecoration(
color: unfilledColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
heightFactor: 1.0,
child: Container(
decoration: BoxDecoration(
color: filledColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(borderRadius),
bottomLeft: Radius.circular(borderRadius),
),
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
// Transport controls - compact sizing to align with mini player
// Play/Pause and Next only shown when powered with content
if (player.available && player.powered && trackInfo != null) ...[
// Play/Pause - slight nudge right
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor,
size: 28,
),
onPressed: onPlayPause,
padding: EdgeInsets.zero,
),
),
// Border overlay - renders ON TOP to prevent clipping by album art
if (widget.isGrouped)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: PlayerCard.groupBorderColor, width: 1.5),
),
),
),
],
),
);
},
);
}
Widget _buildCardContent(double cardHeight, double artSize, double borderRadius) {
return Container(
height: cardHeight,
decoration: BoxDecoration(
color: widget.backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
// Album art or speaker icon - same size for consistent text alignment
SizedBox(
width: artSize,
height: artSize,
child: widget.albumArtUrl != null
? CachedNetworkImage(
imageUrl: widget.albumArtUrl!,
fit: BoxFit.cover,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildSpeakerIcon(),
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
)
: _buildSpeakerIcon(),
),
// Player info
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Player name
Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getStatusColor(),
),
),
Expanded(
child: Text(
widget.player.name,
style: TextStyle(
color: widget.textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 2),
// Track name or status
Text(
_getSubtitle(context),
style: TextStyle(
color: widget.textColor.withOpacity(0.6),
fontSize: 14,
fontFamily: 'Roboto',
decoration: TextDecoration.none,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
// Skip next - nudged right to close gap with power
Transform.translate(
offset: const Offset(6, 0),
),
),
// Transport controls - compact sizing to align with mini player
// Play/Pause and Next only shown when powered with content
if (widget.player.available && widget.player.powered && widget.trackInfo != null) ...[
// Play/Pause - slight nudge right
// Touch target increased to 44dp for accessibility (icon remains 28)
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 44,
height: 44,
child: IconButton(
icon: Icon(
Icons.skip_next_rounded,
color: textColor,
widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: widget.textColor,
size: 28,
),
onPressed: onSkipNext,
onPressed: widget.onPlayPause,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
// Power button - smallest
if (player.available)
IconButton(
),
// Skip next - nudged right to close gap with power
Transform.translate(
offset: const Offset(6, 0),
child: IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: player.powered ? textColor : textColor.withOpacity(0.5),
size: 20,
Icons.skip_next_rounded,
color: widget.textColor,
size: 28,
),
onPressed: onPower,
onPressed: widget.onSkipNext,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
),
],
),
// Power button - smallest
if (widget.player.available)
IconButton(
icon: Icon(
Icons.power_settings_new_rounded,
color: widget.player.powered ? widget.textColor : widget.textColor.withOpacity(0.5),
size: 20,
),
onPressed: widget.onPower,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
),
);
}
void _onDragStart(DragStartDetails details) {
// Get current volume from player
final currentVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
setState(() {
_isDraggingVolume = true;
_dragVolumeLevel = currentVolume;
});
HapticFeedback.lightImpact();
}
void _onDragUpdate(DragUpdateDetails details) {
if (!_isDraggingVolume || _cardWidth <= 0) return;
// Directional volume: drag right = increase, drag left = decrease
// Distance determines amount of change (full card width = 100%)
// Multiple swipes accumulate - each starts from current volume
final dragDelta = details.delta.dx;
final volumeDelta = dragDelta / _cardWidth;
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
// Always update visual
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
setState(() {
_dragVolumeLevel = newVolume;
});
// Throttle API calls to prevent flooding
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastVolumeUpdateTime >= _volumeThrottleMs) {
_lastVolumeUpdateTime = now;
widget.onVolumeChange?.call(newVolume);
}
}
}
void _onDragEnd(DragEndDetails details) {
// Send final volume on release
widget.onVolumeChange?.call(_dragVolumeLevel);
setState(() {
_isDraggingVolume = false;
});
HapticFeedback.lightImpact();
}
void _onDragCancel() {
setState(() {
_isDraggingVolume = false;
});
}
Widget _buildSpeakerIcon() {
// Darker shade of the card background to match album art square
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
final iconBgColor = Color.lerp(widget.backgroundColor, Colors.black, 0.15)!;
return Container(
color: iconBgColor,
child: Center(
child: Icon(
Icons.speaker_rounded,
color: textColor.withOpacity(0.4),
color: widget.textColor.withOpacity(0.4),
size: 28,
),
),
@@ -203,30 +348,30 @@ class PlayerCard extends StatelessWidget {
}
Color _getStatusColor() {
if (!player.available) {
if (!widget.player.available) {
return Colors.grey.withOpacity(0.5);
}
if (!player.powered) {
if (!widget.player.powered) {
return Colors.grey;
}
if (isPlaying) {
if (widget.isPlaying) {
return Colors.green;
}
if (trackInfo != null) {
if (widget.trackInfo != null) {
return Colors.orange; // Has content but paused
}
return Colors.grey.shade400; // Idle
}
String _getSubtitle(BuildContext context) {
if (!player.available) {
if (!widget.player.available) {
return S.of(context)!.playerStateUnavailable;
}
if (!player.powered) {
if (!widget.player.powered) {
return S.of(context)!.playerStateOff;
}
if (trackInfo != null) {
return trackInfo.name ?? S.of(context)!.playerStateIdle;
if (widget.trackInfo != null) {
return widget.trackInfo.name ?? S.of(context)!.playerStateIdle;
}
return S.of(context)!.playerStateIdle;
}
+48 -33
View File
@@ -59,19 +59,25 @@ class PlayerControls extends StatelessWidget {
if (isExpanded && expandedElementsOpacity > 0.1)
_buildSecondaryButton(
icon: Icons.shuffle_rounded,
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
// Fix: compute final opacity once to avoid multiplicative opacity bug
color: shuffle == true
? primaryColor.withOpacity(expandedElementsOpacity)
: textColor.withOpacity(0.5 * expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onToggleShuffle,
),
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
// Previous
_buildControlButton(
icon: Icons.skip_previous_rounded,
color: textColor,
size: skipButtonSize,
onPressed: onPrevious,
useAnimation: isExpanded,
Semantics(
button: true,
label: 'Previous track',
child: _buildControlButton(
icon: Icons.skip_previous_rounded,
color: textColor,
size: skipButtonSize,
onPressed: onPrevious,
useAnimation: isExpanded,
),
),
SizedBox(width: _lerpDouble(0, 20, t)),
@@ -80,12 +86,16 @@ class PlayerControls extends StatelessWidget {
SizedBox(width: _lerpDouble(0, 20, t)),
// Next
_buildControlButton(
icon: Icons.skip_next_rounded,
color: textColor,
size: skipButtonSize,
onPressed: onNext,
useAnimation: isExpanded,
Semantics(
button: true,
label: 'Next track',
child: _buildControlButton(
icon: Icons.skip_next_rounded,
color: textColor,
size: skipButtonSize,
onPressed: onNext,
useAnimation: isExpanded,
),
),
// Repeat (expanded only)
@@ -94,10 +104,10 @@ class PlayerControls extends StatelessWidget {
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),
// Fix: compute final opacity once to avoid multiplicative opacity bug
color: (repeatMode != null && repeatMode != 'off')
? primaryColor.withOpacity(expandedElementsOpacity)
: textColor.withOpacity(0.5 * expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onCycleRepeat,
),
],
@@ -151,21 +161,26 @@ class PlayerControls extends StatelessWidget {
final bgColor = Color.lerp(Colors.transparent, primaryColor, progress);
final iconColor = Color.lerp(textColor, backgroundColor, progress);
return GestureDetector(
onLongPress: onStop,
child: Container(
width: playButtonContainerSize,
height: playButtonContainerSize,
decoration: BoxDecoration(
color: bgColor,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
color: iconColor,
iconSize: playButtonSize,
onPressed: onPlayPause,
padding: EdgeInsets.zero,
return Semantics(
button: true,
label: isPlaying ? 'Pause' : 'Play',
hint: 'Long press to stop',
child: GestureDetector(
onLongPress: onStop,
child: Container(
width: playButtonContainerSize,
height: playButtonContainerSize,
decoration: BoxDecoration(
color: bgColor,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
color: iconColor,
iconSize: playButtonSize,
onPressed: onPlayPause,
padding: EdgeInsets.zero,
),
),
),
);
+213 -10
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
@@ -40,13 +44,25 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
double _dragOffset = 0;
bool _isDragging = false;
// Scroll controller for when player list overflows
final ScrollController _scrollController = ScrollController();
bool _isScrollable = false;
// Cache extracted colors per player for per-device accent colors
final Map<String, ColorScheme?> _playerColors = {};
// Hint system
bool _showHints = true;
@override
void initState() {
super.initState();
// Load hint settings
SettingsService.getShowHints().then((value) {
if (mounted) setState(() => _showHints = value);
});
_revealController = AnimationController(
vsync: this,
);
@@ -116,6 +132,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
void dispose() {
_revealController.dispose();
_refreshTimer?.cancel();
_scrollController.dispose();
super.dispose();
}
@@ -127,7 +144,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() {
@@ -145,19 +162,51 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
});
}
/// Check if scroll is at top (or not scrollable)
bool get _isAtTop {
if (!_isScrollable) return true;
if (!_scrollController.hasClients) return true;
return _scrollController.offset <= 0;
}
void _handleVerticalDragUpdate(DragUpdateDetails details) {
final delta = details.primaryDelta ?? 0;
// If scrollable and not at top, let scroll handle it
if (_isScrollable && !_isAtTop) {
// Scroll the list
if (_scrollController.hasClients) {
final newOffset = _scrollController.offset - delta;
_scrollController.jumpTo(newOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
}
return;
}
// At top: check if trying to scroll down (view more) or dismiss
if (_isScrollable && delta < 0 && _scrollController.hasClients) {
// Swiping up (delta negative) = scroll down to see more players
final newOffset = _scrollController.offset - delta;
_scrollController.jumpTo(newOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
return;
}
// Only process dismiss gesture when swiping DOWN (positive delta = finger moving down)
// Ignore swipe up when not scrollable - cards should stay fixed
if (delta <= 0) return;
// Swiping down = dismiss gesture
setState(() {
_isDragging = true;
// Negate delta so swipe down = cards move down (toward dismissal)
_dragOffset -= details.primaryDelta ?? 0;
// Clamp: allow moving down (positive) freely, limit moving up to 20px
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
_dragOffset += delta;
});
}
void _handleVerticalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0;
// If not dragging (was scrolling), don't dismiss
if (!_isDragging) return;
// Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) {
HapticFeedback.lightImpact();
@@ -203,6 +252,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
const cardSpacing = 12.0;
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
// Calculate max available height (80% of screen minus mini player area)
final screenHeight = MediaQuery.of(context).size.height;
final statusBarHeight = MediaQuery.of(context).padding.top;
final bottomPadding = widget.miniPlayerBottom + widget.miniPlayerHeight + 20;
final maxListHeight = (screenHeight - statusBarHeight - bottomPadding) * 0.8;
// Determine if scrolling is needed
final needsScroll = totalStackHeight > maxListHeight;
// Update scrollable state (used by gesture handlers)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isScrollable != needsScroll) {
_isScrollable = needsScroll;
}
});
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
@@ -210,13 +274,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,7 +294,134 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Build player cards - all slide from behind mini player
// 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;
Widget buildHintRow(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18,
color: colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 6),
Text(
text,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
);
}
return Transform.translate(
offset: Offset(0, hintSlideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Material(
type: MaterialType.transparency,
child: isOnboarding
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
const SizedBox(height: 4),
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
],
),
),
),
);
},
),
// Build player cards - scrollable when overflow, otherwise static
// Using ListView.builder for many players: only builds visible items
// This significantly improves animation performance with 10+ players
if (needsScroll)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxListHeight),
child: ListView.builder(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
itemCount: players.length,
// Fixed height per item for optimal scroll performance
itemExtent: cardHeight + cardSpacing,
itemBuilder: (context, index) {
final player = players[index];
final isPlaying = player.state == 'playing';
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
String? albumArtUrl;
if (playerTrack != null && player.available && player.powered) {
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
}
final playerColorScheme = _playerColors[player.playerId];
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
// Animation: slide from behind mini player
// Only calculate for items that will be built (visible ones)
const baseOffset = 80.0;
final reverseIndex = players.length - 1 - index;
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
final slideOffset = distanceToTravel * (1.0 - t);
return Transform.translate(
offset: Offset(0, slideOffset),
child: Padding(
padding: const EdgeInsets.only(bottom: cardSpacing),
child: PlayerCard(
player: player,
trackInfo: playerTrack,
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
HapticFeedback.mediumImpact();
maProvider.selectPlayer(player);
dismiss();
},
onLongPress: () {
HapticFeedback.mediumImpact();
maProvider.togglePlayerSync(player.playerId);
},
onPlayPause: () {
if (isPlaying) {
maProvider.pausePlayer(player.playerId);
} else {
maProvider.resumePlayer(player.playerId);
}
},
onSkipNext: () => maProvider.nextTrack(player.playerId),
onPower: () => maProvider.togglePower(player.playerId),
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
),
),
);
},
),
)
else
// Non-scrollable list for when players fit
...List.generate(players.length, (index) {
final player = players[index];
final isPlaying = player.state == 'playing';
@@ -269,6 +459,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
albumArtUrl: albumArtUrl,
isSelected: false,
isPlaying: isPlaying,
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
backgroundColor: cardBgColor,
textColor: cardTextColor,
onTap: () {
@@ -276,6 +467,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);
@@ -289,6 +491,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
onPower: () {
maProvider.togglePower(player.playerId);
},
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
),
),
);
+226
View File
@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import '../models/player.dart';
import '../l10n/app_localizations.dart';
/// A themed bottom sheet for selecting a player.
///
/// Supports adaptive theming - pass [adaptiveColorScheme] to use colors
/// extracted from album art, matching the detail screen backgrounds.
class PlayerPickerSheet extends StatelessWidget {
final String title;
final List<Player> players;
final Player? selectedPlayer;
final ColorScheme? adaptiveColorScheme;
final Future<void> Function(Player player) onPlayerSelected;
const PlayerPickerSheet({
super.key,
required this.title,
required this.players,
this.selectedPlayer,
this.adaptiveColorScheme,
required this.onPlayerSelected,
});
@override
Widget build(BuildContext context) {
final themeColorScheme = Theme.of(context).colorScheme;
final colorScheme = adaptiveColorScheme ?? themeColorScheme;
// Use surface container for slightly elevated look, or adaptive surface
final backgroundColor = adaptiveColorScheme?.surface ?? themeColorScheme.surfaceContainerHigh;
final onBackgroundColor = adaptiveColorScheme?.onSurface ?? themeColorScheme.onSurface;
return Container(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: onBackgroundColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
// Title
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: onBackgroundColor,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 8),
// Player list
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(
S.of(context)!.noPlayersAvailable,
style: TextStyle(color: onBackgroundColor.withOpacity(0.6)),
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: players.length,
itemBuilder: (context, index) {
final player = players[index];
final isSelected = selectedPlayer?.playerId == player.playerId;
return _PlayerTile(
player: player,
isSelected: isSelected,
colorScheme: colorScheme,
onBackgroundColor: onBackgroundColor,
onTap: () => onPlayerSelected(player),
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 70),
],
),
);
}
}
class _PlayerTile extends StatelessWidget {
final Player player;
final bool isSelected;
final ColorScheme colorScheme;
final Color onBackgroundColor;
final VoidCallback onTap;
const _PlayerTile({
required this.player,
required this.isSelected,
required this.colorScheme,
required this.onBackgroundColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
// Status color based on player state
Color statusColor;
if (!player.available) {
statusColor = Colors.grey.withOpacity(0.5);
} else if (!player.powered) {
statusColor = Colors.grey;
} else if (player.state == 'playing') {
statusColor = Colors.green;
} else {
statusColor = Colors.orange; // Idle/paused but powered
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withOpacity(0.3)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Status indicator
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
),
const SizedBox(width: 12),
// Speaker icon
Icon(
Icons.speaker_rounded,
color: isSelected
? colorScheme.primary
: onBackgroundColor.withOpacity(0.7),
size: 24,
),
const SizedBox(width: 12),
// Player name
Expanded(
child: Text(
player.name,
style: TextStyle(
color: isSelected
? colorScheme.primary
: onBackgroundColor,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
fontSize: 16,
),
),
),
// Selected checkmark
if (isSelected)
Icon(
Icons.check_circle_rounded,
color: colorScheme.primary,
size: 20,
),
],
),
),
),
),
);
}
}
/// Shows the player picker bottom sheet with adaptive theming.
///
/// Returns the selected player, or null if dismissed.
Future<void> showPlayerPickerSheet({
required BuildContext context,
required String title,
required List<Player> players,
Player? selectedPlayer,
ColorScheme? adaptiveColorScheme,
required Future<void> Function(Player player) onPlayerSelected,
}) {
return showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => PlayerPickerSheet(
title: title,
players: players,
selectedPlayer: selectedPlayer,
adaptiveColorScheme: adaptiveColorScheme,
onPlayerSelected: (player) async {
Navigator.pop(context);
await onPlayerSelected(player);
},
),
);
}
+89 -19
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,8 +19,13 @@ class PlayerSelector extends StatelessWidget {
final availablePlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing
final playingCount = availablePlayers.where((p) => p.state == 'playing').length;
// Count players that are currently playing MA content (excluding selected player)
// User already sees if selected player is playing, badge shows "other" activity
// Exclude external sources (Spotify Connect, TV optical, etc.) - they're not playing MA content
final selectedPlayerId = selectedPlayer?.playerId;
final playingCount = availablePlayers
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId && !p.isExternalSource)
.length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
@@ -197,23 +203,34 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
// but still have track info - treat as "has content" for display
final isIdle = player.state == 'idle';
// Check for external source (optical, Spotify, etc.)
final isExternalSource = player.isExternalSource;
// Get track info - use current track for selected player, cache for others
final playerTrack = isSelected
? currentTrack
: maProvider.getCachedTrackForPlayer(player.playerId);
// Skip track info for external sources
final playerTrack = isExternalSource
? null
: (isSelected
? currentTrack
: maProvider.getCachedTrackForPlayer(player.playerId));
// Player has content if playing, paused, or idle with cached track
final hasContent = isPlaying || isPaused || (isIdle && playerTrack != null);
// Player has content if playing, paused, or idle with cached track (but not external source)
final hasContent = !isExternalSource && (isPlaying || isPaused || (isIdle && playerTrack != null));
// Get album art for any player with content
// Get album art for any player with content (skip for external source)
String? albumArtUrl;
if (playerTrack != null && isOn && hasContent) {
if (!isExternalSource && playerTrack != null && isOn && hasContent) {
albumArtUrl = maProvider.getImageUrl(
playerTrack.album ?? playerTrack,
size: 128,
);
}
// Check if this player is manually synced (not a pre-configured group)
final isGrouped = maProvider.isPlayerManuallySynced(player.playerId);
// Pastel yellow for grouped players
const groupBorderColor = Color(0xFFFFF59D);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
@@ -221,6 +238,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,
@@ -229,6 +263,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: [
@@ -291,11 +328,13 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? colorScheme.onSurface.withOpacity(0.24)
: !isOn
? colorScheme.onSurface.withOpacity(0.3)
: isPlaying
? Colors.green
: hasContent
? Colors.orange // paused or idle with content
: colorScheme.onSurfaceVariant.withOpacity(0.5),
: isExternalSource
? Colors.cyan // external source (optical, Spotify, etc.)
: isPlaying
? Colors.green
: hasContent
? Colors.orange // paused or idle with content
: colorScheme.onSurfaceVariant.withOpacity(0.5),
),
),
const SizedBox(width: 6),
@@ -305,9 +344,11 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? S.of(context)!.playerStateUnavailable
: !isOn
? S.of(context)!.playerStateOff
: hasContent && playerTrack != null
? playerTrack.name
: S.of(context)!.playerStateIdle,
: isExternalSource
? S.of(context)!.playerStateExternalSource
: hasContent && playerTrack != null
? playerTrack.name
: S.of(context)!.playerStateIdle,
style: TextStyle(
color: player.available
? colorScheme.onSurfaceVariant
@@ -318,6 +359,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(
@@ -341,8 +411,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
],
),
),
// Play/Pause button
if (player.available && isOn)
// Play/Pause button (hidden for external sources - controls don't work)
if (player.available && isOn && !isExternalSource)
IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
+2 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.4.1-beta+26
version: 2.7.4-beta+36
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -28,6 +28,7 @@ dependencies:
# Storage
shared_preferences: ^2.2.2
flutter_secure_storage: ^9.2.2
drift: ^2.22.0
drift_flutter: 0.2.0
path_provider: ^2.1.5