Compare commits

...
119 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
38 changed files with 4915 additions and 1006 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 |
+83 -2
View File
@@ -25,6 +25,8 @@ class Profiles extends Table {
} }
/// Recently played items - scoped by profile /// 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 { class RecentlyPlayed extends Table {
/// Auto-incrementing ID /// Auto-incrementing ID
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
@@ -55,6 +57,8 @@ class RecentlyPlayed extends Table {
} }
/// Cached library items for fast startup /// 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 { class LibraryCache extends Table {
/// Composite key: provider + item_id /// Composite key: provider + item_id
TextColumn get cacheKey => text()(); TextColumn get cacheKey => text()();
@@ -139,6 +143,8 @@ class CachedPlayers extends Table {
} }
/// Cached queue items for selected player /// 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 { class CachedQueue extends Table {
/// Auto-incrementing ID for ordering /// Auto-incrementing ID for ordering
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
@@ -168,12 +174,24 @@ class HomeRowCache extends Table {
Set<Column> get primaryKey => {rowType}; Set<Column> get primaryKey => {rowType};
} }
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache]) /// 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 { class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 3; int get schemaVersion => 5;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -192,6 +210,22 @@ class AppDatabase extends _$AppDatabase {
if (from < 3) { if (from < 3) {
await m.createTable(homeRowCache); 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)');
}
}, },
); );
} }
@@ -569,4 +603,51 @@ class AppDatabase extends _$AppDatabase {
Future<void> clearHomeRowCache() async { Future<void> clearHomeRowCache() async {
await delete(homeRowCache).go(); 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();
}
} }
+3 -1
View File
@@ -279,6 +279,7 @@
"playerStateUnavailable": "Nicht verfügbar", "playerStateUnavailable": "Nicht verfügbar",
"playerStateOff": "Aus", "playerStateOff": "Aus",
"playerStateIdle": "Inaktiv", "playerStateIdle": "Inaktiv",
"playerStateExternalSource": "Externe Quelle",
"playerSelected": "Ausgewählt", "playerSelected": "Ausgewählt",
"actionQueuedForSync": "Wird synchronisiert wenn online", "actionQueuedForSync": "Wird synchronisiert wenn online",
"pendingOfflineActions": "{count} ausstehende Synchronisierung", "pendingOfflineActions": "{count} ausstehende Synchronisierung",
@@ -286,5 +287,6 @@
"showHints": "Hinweise anzeigen", "showHints": "Hinweise anzeigen",
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen", "showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen", "pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
"holdToSync": "Gerät gedrückt halten zum Synchronisieren" "holdToSync": "Gedrückt halten zum Sync",
"swipeToAdjustVolume": "Wischen für Lautstärke"
} }
+46 -1
View File
@@ -261,6 +261,26 @@
"description": "Search hint text" "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": "Retry",
"@retry": { "@retry": {
"description": "Retry button label" "description": "Retry button label"
@@ -610,6 +630,11 @@
"description": "Radio button label" "description": "Radio button label"
}, },
"addedToQueue": "Added to queue",
"@addedToQueue": {
"description": "Confirmation when item added to queue"
},
"inLibrary": "In Library", "inLibrary": "In Library",
"@inLibrary": { "@inLibrary": {
"description": "In library section title" "description": "In library section title"
@@ -1133,6 +1158,16 @@
"description": "Singular form of audiobook" "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": "{count} {count, plural, =1{track} other{tracks}}",
"@trackCount": { "@trackCount": {
"description": "Track count with plural", "description": "Track count with plural",
@@ -1193,6 +1228,11 @@
"description": "Player state when idle" "description": "Player state when idle"
}, },
"playerStateExternalSource": "External Source",
"@playerStateExternalSource": {
"description": "Player state when playing from external source (optical, Spotify, etc.)"
},
"playerSelected": "Selected", "playerSelected": "Selected",
"@playerSelected": { "@playerSelected": {
"description": "Label for selected player" "description": "Label for selected player"
@@ -1233,11 +1273,16 @@
"description": "Hint shown when mini player bounces" "description": "Hint shown when mini player bounces"
}, },
"holdToSync": "Tap and hold a player to sync", "holdToSync": "Long-press to sync",
"@holdToSync": { "@holdToSync": {
"description": "Hint for long-press to sync players" "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": "Choose a player, or dismiss by swiping down",
"@selectPlayerHint": { "@selectPlayerHint": {
"description": "First-time hint for selecting a player in device selector" "description": "First-time hint for selecting a player in device selector"
+3 -1
View File
@@ -475,6 +475,7 @@
"playerStateUnavailable": "No disponible", "playerStateUnavailable": "No disponible",
"playerStateOff": "Apagado", "playerStateOff": "Apagado",
"playerStateIdle": "Inactivo", "playerStateIdle": "Inactivo",
"playerStateExternalSource": "Fuente Externa",
"playerSelected": "Seleccionado", "playerSelected": "Seleccionado",
"actionQueuedForSync": "Se sincronizará cuando esté en línea", "actionQueuedForSync": "Se sincronizará cuando esté en línea",
"pendingOfflineActions": "{count} sincronización pendiente", "pendingOfflineActions": "{count} sincronización pendiente",
@@ -482,5 +483,6 @@
"showHints": "Mostrar Sugerencias", "showHints": "Mostrar Sugerencias",
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones", "showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos", "pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado un dispositivo para sincronizar" "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) // Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName(); 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 // Load library from cache for instant startup
await SyncService.instance.loadFromCache(); await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded'); _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 { class AppStartup extends StatefulWidget {
const AppStartup({super.key}); const AppStartup({super.key});
@@ -361,9 +374,32 @@ class _AppStartupState extends State<AppStartup> {
String? _savedServerUrl; String? _savedServerUrl;
bool _connectionAttempted = false; bool _connectionAttempted = false;
// Welcome system state - loaded FIRST before connection check
bool _settingsLoaded = false;
bool _hasCompletedOnboarding = true; // Default to true (no welcome needed)
@override @override
void initState() { void initState() {
super.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(); _checkAndConnect();
} }
@@ -388,14 +424,30 @@ class _AppStartupState extends State<AppStartup> {
// Just wait for it to complete or timeout // Just wait for it to complete or timeout
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl'); _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) // Give the provider time to connect (it restores credentials and connects in _initialize)
// Check connection state periodically // Check connection state periodically
for (var i = 0; i < 20; i++) { for (var i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250)); await Future.delayed(const Duration(milliseconds: 250));
if (provider.isConnected) {
_logger.log('🚀 AppStartup: Connection established'); if (needsWelcome) {
break; // 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) { if (provider.connectionState == MAConnectionState.error) {
_logger.log('🚀 AppStartup: Connection failed with error'); _logger.log('🚀 AppStartup: Connection failed with error');
break; break;
@@ -422,7 +474,8 @@ class _AppStartupState extends State<AppStartup> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Show loading while checking settings or connecting // 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( return Scaffold(
backgroundColor: const Color(0xFF1a1a1a), backgroundColor: const Color(0xFF1a1a1a),
body: Center( body: Center(
+98 -7
View File
@@ -14,6 +14,9 @@ class Player {
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated 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 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? 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({ Player({
required this.playerId, required this.playerId,
@@ -29,6 +32,9 @@ class Player {
this.elapsedTimeLastUpdated, this.elapsedTimeLastUpdated,
this.groupMembers, this.groupMembers,
this.syncedTo, this.syncedTo,
this.activeSource,
this.isExternalSource = false,
this.appId,
}); });
/// Create a copy of this Player with some fields replaced /// Create a copy of this Player with some fields replaced
@@ -46,6 +52,9 @@ class Player {
double? elapsedTimeLastUpdated, double? elapsedTimeLastUpdated,
List<String>? groupMembers, List<String>? groupMembers,
String? syncedTo, String? syncedTo,
String? activeSource,
bool? isExternalSource,
String? appId,
}) { }) {
return Player( return Player(
playerId: playerId ?? this.playerId, playerId: playerId ?? this.playerId,
@@ -61,6 +70,9 @@ class Player {
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated, elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
groupMembers: groupMembers ?? this.groupMembers, groupMembers: groupMembers ?? this.groupMembers,
syncedTo: syncedTo ?? this.syncedTo, syncedTo: syncedTo ?? this.syncedTo,
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId,
); );
} }
@@ -75,8 +87,31 @@ class Player {
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null; bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
bool get isGroupChild => 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) // Track when this Player object was created (for local interpolation fallback)
static final Map<String, double> _playerCreationTimes = {}; 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) // Calculate current elapsed time (interpolated if playing)
double get currentElapsedTime { double get currentElapsedTime {
@@ -118,13 +153,8 @@ class Player {
if (!_playerCreationTimes.containsKey(creationKey)) { if (!_playerCreationTimes.containsKey(creationKey)) {
// First time seeing this elapsed_time - record when we saw it // First time seeing this elapsed_time - record when we saw it
_playerCreationTimes[creationKey] = now; _playerCreationTimes[creationKey] = now;
// Clean up old entries to prevent memory leak // Clean up old entries to prevent memory leak (LRU eviction)
if (_playerCreationTimes.length > 100) { _cleanupCreationTimes();
final keysToRemove = _playerCreationTimes.keys.take(50).toList();
for (final key in keysToRemove) {
_playerCreationTimes.remove(key);
}
}
} }
final creationTime = _playerCreationTimes[creationKey]!; final creationTime = _playerCreationTimes[creationKey]!;
@@ -185,6 +215,61 @@ class Player {
// Parse synced_to - the player ID this player is synced to // Parse synced_to - the player ID this player is synced to
final syncedTo = json['synced_to'] as String?; 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( return Player(
playerId: json['player_id'] as String, playerId: json['player_id'] as String,
name: json['name'] as String, name: json['name'] as String,
@@ -199,6 +284,9 @@ class Player {
elapsedTimeLastUpdated: elapsedTimeLastUpdated, elapsedTimeLastUpdated: elapsedTimeLastUpdated,
groupMembers: groupMembers, groupMembers: groupMembers,
syncedTo: syncedTo, syncedTo: syncedTo,
activeSource: activeSource,
isExternalSource: isExternalSource,
appId: appId,
); );
} }
@@ -217,6 +305,9 @@ class Player {
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated, if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
if (groupMembers != null) 'group_members': groupMembers, if (groupMembers != null) 'group_members': groupMembers,
if (syncedTo != null) 'synced_to': syncedTo, 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) { if (playerToSelect == null) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId(); final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Priority 1: Last selected player
if (lastSelectedPlayerId != null) { if (lastSelectedPlayerId != null) {
try { playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
playerToSelect = _availablePlayers.firstWhere( (p) => p!.playerId == lastSelectedPlayerId && p.available,
(p) => p.playerId == lastSelectedPlayerId && p.available, orElse: () => null,
); );
} catch (e) {}
} }
// Priority 2: Built-in (local) player
if (playerToSelect == null && builtinPlayerId != null) { if (playerToSelect == null && builtinPlayerId != null) {
try { playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
playerToSelect = _availablePlayers.firstWhere( (p) => p!.playerId == builtinPlayerId && p.available,
(p) => p.playerId == builtinPlayerId && p.available, orElse: () => null,
); );
} catch (e) {}
} }
// Priority 3: Currently playing player (exclude external sources - not playing MA content)
if (playerToSelect == null) { if (playerToSelect == null) {
try { playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
playerToSelect = _availablePlayers.firstWhere( (p) => p!.state == 'playing' && p.available && !p.isExternalSource,
(p) => p.state == 'playing' && p.available, orElse: () => null,
); );
} catch (e) {}
} }
if (playerToSelect == 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; if (_api == null) return;
try { try {
await _api!.toggleShuffle(queueId); await _api!.toggleShuffle(queueId, shuffleEnabled);
} catch (e) { } catch (e) {
ErrorHandler.logError('Toggle shuffle', e); ErrorHandler.logError('Toggle shuffle', e);
rethrow; rethrow;
+49 -97
View File
@@ -10,6 +10,7 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import 'artist_details_screen.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( child: Scaffold(
backgroundColor: colorScheme.background, 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: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 350, // Increased height for bigger art expandedHeight: expandedHeight,
pinned: true, pinned: true,
backgroundColor: colorScheme.background, backgroundColor: colorScheme.background,
leading: IconButton( leading: IconButton(
@@ -727,11 +734,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: Hero( child: Hero(
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix, tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
child: Container( child: Container(
width: 280, // Increased size width: coverSize,
height: 280, height: coverSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceVariant, color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(16), // Slightly more rounded borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3), color: Colors.black.withOpacity(0.3),
@@ -749,7 +756,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: imageUrl == null child: imageUrl == null
? Icon( ? Icon(
Icons.album_rounded, Icons.album_rounded,
size: 120, size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
) )
: null, : null,
@@ -906,14 +913,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
], ],
), ),
const SizedBox(height: 24), 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 const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
], ],
), );
},
),
), ),
); );
} }
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) { void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
_showPlayOnSheet( GlobalPlayerOverlay.hidePlayer();
context,
players, showPlayerPickerSheet(
onPlayerSelected: (player) { context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
maProvider.playTracks( await maProvider.playTracks(
player.playerId, player.playerId,
_tracks, _tracks,
startIndex: startIndex, startIndex: startIndex,
); );
}, },
); ).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
} }
void _showPlayRadioMenu(BuildContext context, int trackIndex) { void _showPlayRadioMenu(BuildContext context, int trackIndex) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
final track = _tracks[trackIndex]; final track = _tracks[trackIndex];
_showPlayOnSheet( GlobalPlayerOverlay.hidePlayer();
context,
players, showPlayerPickerSheet(
onPlayerSelected: (player) { context: context,
title: S.of(context)!.playOn,
players: maProvider.availablePlayers,
selectedPlayer: maProvider.selectedPlayer,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player); maProvider.selectPlayer(player);
maProvider.playRadio(player.playerId, track); await maProvider.playRadio(player.playerId, track);
}, },
); ).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
} }
void _showPlayOnMenu(BuildContext context) { void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); 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(); GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height; showPlayerPickerSheet(
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding; // Fixed 45% + safe area
showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.transparent, title: S.of(context)!.playOn,
isScrollControlled: true, players: maProvider.availablePlayers,
builder: (context) => Container( selectedPlayer: maProvider.selectedPlayer,
height: sheetHeight, onPlayerSelected: (player) async {
decoration: BoxDecoration( maProvider.selectPlayer(player);
color: Theme.of(context).colorScheme.surface, await maProvider.playTracks(player.playerId, _tracks);
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);
},
);
},
),
),
],
),
),
).whenComplete(() { ).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer(); GlobalPlayerOverlay.showPlayer();
}); });
} }
+117 -101
View File
@@ -243,57 +243,25 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
} }
} }
Future<void> _startArtistRadio() async { void _showRadioMenu(BuildContext context) {
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) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers; final players = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: colorScheme.surface, backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder( builder: (context) => Container(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), decoration: BoxDecoration(
), color: Theme.of(context).colorScheme.surface,
builder: (context) => Padding( borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
padding: const EdgeInsets.all(16.0), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 16),
Text( Text(
S.of(context)!.startRadioOn(widget.artist.name), S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
@@ -314,7 +282,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return ListTile( return ListTile(
leading: Icon( leading: Icon(
Icons.speaker, Icons.speaker,
color: colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
title: Text(player.name), title: Text(player.name),
onTap: () async { 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 maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer; final players = maProvider.availablePlayers;
if (selectedPlayer == null) { // Slide mini player down out of the way
ScaffoldMessenger.of(context).showSnackBar( GlobalPlayerOverlay.hidePlayer();
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try { showModalBottomSheet(
// For adding to queue with radio mode, we use radio_mode but with 'add' option context: context,
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist); backgroundColor: Colors.transparent,
if (mounted) { builder: (context) => Container(
ScaffoldMessenger.of(context).showSnackBar( decoration: BoxDecoration(
SnackBar( color: Theme.of(context).colorScheme.surface,
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)), borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
duration: const Duration(seconds: 1), ),
), child: Column(
); mainAxisSize: MainAxisSize.min,
} children: [
} catch (e) { const SizedBox(height: 16),
_logger.log('Error adding artist radio to queue: $e'); Text(
if (mounted) { S.of(context)!.addToQueueOn,
ScaffoldMessenger.of(context).showSnackBar( style: Theme.of(context).textTheme.titleLarge,
SnackBar( ),
content: Text(S.of(context)!.failedToAddToQueue(e.toString())), const SizedBox(height: 16),
backgroundColor: Colors.red, 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 { Future<void> _loadArtistImage() async {
@@ -526,10 +551,16 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}, },
child: Scaffold( child: Scaffold(
backgroundColor: colorScheme.background, 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: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 300, expandedHeight: expandedHeight,
pinned: true, pinned: true,
backgroundColor: colorScheme.background, backgroundColor: colorScheme.background,
leading: IconButton( leading: IconButton(
@@ -549,8 +580,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix, tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
child: ClipOval( child: ClipOval(
child: Container( child: Container(
width: 200, width: coverSize,
height: 200, height: coverSize,
color: colorScheme.surfaceVariant, color: colorScheme.surfaceVariant,
child: imageUrl != null child: imageUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
@@ -560,13 +591,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Icon( errorWidget: (_, __, ___) => Icon(
Icons.person_rounded, Icons.person_rounded,
size: 100, size: coverSize * 0.5,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
) )
: Icon( : Icon(
Icons.person_rounded, Icons.person_rounded,
size: 100, size: coverSize * 0.5,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
@@ -627,7 +658,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _startArtistRadio, onPressed: () => _showRadioMenu(context),
icon: const Icon(Icons.radio), icon: const Icon(Icons.radio),
label: Text(S.of(context)!.radio), label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom( 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), const SizedBox(width: 12),
@@ -665,7 +679,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
height: 50, height: 50,
width: 50, width: 50,
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: _addArtistRadioToQueue, onPressed: () => _showAddToQueueMenu(context),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( 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 '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/player_picker_sheet.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -209,62 +210,22 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
void _showPlayOnMenu(BuildContext context) { void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer(); GlobalPlayerOverlay.hidePlayer();
final screenHeight = MediaQuery.of(context).size.height; showPlayerPickerSheet(
final bottomPadding = MediaQuery.of(context).padding.bottom;
final sheetHeight = (screenHeight * 0.45) + bottomPadding;
showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.transparent, title: S.of(context)!.playOn,
isScrollControlled: true, players: maProvider.availablePlayers,
builder: (context) => Container( selectedPlayer: maProvider.selectedPlayer,
height: sheetHeight, onPlayerSelected: (player) async {
decoration: BoxDecoration( maProvider.selectPlayer(player);
color: Theme.of(context).colorScheme.surface, maProvider.setCurrentAudiobook(_audiobook);
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), await maProvider.api?.playAudiobook(
), player.playerId,
child: Column( widget.audiobook,
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,
);
},
);
},
),
),
],
),
),
).whenComplete(() { ).whenComplete(() {
GlobalPlayerOverlay.showPlayer(); GlobalPlayerOverlay.showPlayer();
}); });
@@ -466,10 +427,16 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
}, },
child: Scaffold( child: Scaffold(
backgroundColor: colorScheme.surface, 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: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 350, expandedHeight: expandedHeight,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
leading: IconButton( leading: IconButton(
@@ -538,8 +505,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
width: 280, width: coverSize,
height: 280, height: coverSize,
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: imageUrl != null child: imageUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
@@ -550,14 +517,14 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
placeholder: (_, __) => Center( placeholder: (_, __) => Center(
child: Icon( child: Icon(
MdiIcons.bookOutline, MdiIcons.bookOutline,
size: 120, size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
errorWidget: (_, __, ___) => Center( errorWidget: (_, __, ___) => Center(
child: Icon( child: Icon(
MdiIcons.bookOutline, MdiIcons.bookOutline,
size: 120, size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
@@ -565,7 +532,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
: Center( : Center(
child: Icon( child: Icon(
MdiIcons.bookOutline, MdiIcons.bookOutline,
size: 120, size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
@@ -809,6 +776,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
const SliverToBoxAdapter(child: SizedBox(height: 140)), const SliverToBoxAdapter(child: SizedBox(height: 140)),
], ],
);
},
), ),
), ),
); );
+14 -6
View File
@@ -275,11 +275,17 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
return Scaffold( return Scaffold(
backgroundColor: colorScheme.surface, 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: [ slivers: [
// App bar with series cover collage (matches audiobook detail screen) // App bar with series cover collage (matches audiobook detail screen)
SliverAppBar( SliverAppBar(
expandedHeight: 350, expandedHeight: expandedHeight,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
leading: IconButton( leading: IconButton(
@@ -292,12 +298,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const SizedBox(height: 60), const SizedBox(height: 60),
// Series cover collage - same size as audiobook detail cover // Series cover collage - responsive size
Hero( Hero(
tag: widget.heroTag ?? 'series_cover_${widget.series.id}', tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: Container( child: Container(
width: 280, width: coverSize,
height: 280, height: coverSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -445,10 +451,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
_buildAudiobookSliver(maProvider), _buildAudiobookSliver(maProvider),
// Bottom padding for mini player // Bottom padding for mini player
const SliverToBoxAdapter( SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer), child: SizedBox(height: BottomSpacing.withMiniPlayer),
), ),
], ],
);
},
), ),
); );
} }
+12
View File
@@ -443,6 +443,18 @@ class _LoginScreenState extends State<LoginScreen> {
} }
if (provider.isConnected) { 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 // Navigate to home screen
if (mounted) { if (mounted) {
// Ensure keyboard is closed before navigating // Ensure keyboard is closed before navigating
File diff suppressed because it is too large Load Diff
+20 -4
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart'; import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
@@ -11,6 +12,12 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
final AuthManager authManager; final AuthManager authManager;
final _logger = DebugLogger(); 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 // Track current metadata separately from what's in the notification
// This allows us to update the notification when metadata arrives late // This allows us to update the notification when metadata arrives late
MediaItem? _currentMediaItem; MediaItem? _currentMediaItem;
@@ -42,7 +49,7 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
await session.configure(const AudioSessionConfiguration.music()); await session.configure(const AudioSessionConfiguration.music());
// Handle audio interruptions // Handle audio interruptions
session.interruptionEventStream.listen((event) { _interruptionSubscription = session.interruptionEventStream.listen((event) {
if (event.begin) { if (event.begin) {
switch (event.type) { switch (event.type) {
case AudioInterruptionType.duck: case AudioInterruptionType.duck:
@@ -68,15 +75,15 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
}); });
// Handle becoming noisy (headphones unplugged) // Handle becoming noisy (headphones unplugged)
session.becomingNoisyEventStream.listen((_) { _becomingNoisySubscription = session.becomingNoisyEventStream.listen((_) {
pause(); pause();
}); });
// Broadcast playback state changes // Broadcast playback state changes
_player.playbackEventStream.listen(_broadcastState); _playbackEventSubscription = _player.playbackEventStream.listen(_broadcastState);
// Broadcast current media item changes // Broadcast current media item changes
_player.currentIndexStream.listen((_) { _currentIndexSubscription = _player.currentIndexStream.listen((_) {
if (_currentMediaItem != null) { if (_currentMediaItem != null) {
mediaItem.add(_currentMediaItem); mediaItem.add(_currentMediaItem);
} }
@@ -293,4 +300,13 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
Stream<Duration?> get durationStream => _player.durationStream; Stream<Duration?> get durationStream => _player.durationStream;
MediaItem? get currentMediaItem => _currentMediaItem; 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();
}
} }
+52
View File
@@ -11,6 +11,11 @@ class CacheService {
final DebugLogger _logger = DebugLogger(); final DebugLogger _logger = DebugLogger();
bool _homeRowsLoaded = false; 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 // Home screen row caching
List<Album>? _cachedRecentAlbums; List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists; List<Artist>? _cachedDiscoverArtists;
@@ -38,6 +43,7 @@ class CacheService {
// Player track cache (for smooth swipe transitions) // Player track cache (for smooth swipe transitions)
final Map<String, Track?> _playerTrackCache = {}; final Map<String, Track?> _playerTrackCache = {};
final Map<String, DateTime> _playerTrackCacheTime = {};
// ============================================================================ // ============================================================================
// HOME SCREEN ROW CACHING // HOME SCREEN ROW CACHING
@@ -135,6 +141,7 @@ class CacheService {
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) { void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
_albumTracksCache[cacheKey] = tracks; _albumTracksCache[cacheKey] = tracks;
_albumTracksCacheTime[cacheKey] = DateTime.now(); _albumTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey'); _logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
} }
@@ -161,6 +168,7 @@ class CacheService {
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) { void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
_playlistTracksCache[cacheKey] = tracks; _playlistTracksCache[cacheKey] = tracks;
_playlistTracksCacheTime[cacheKey] = DateTime.now(); _playlistTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey'); _logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
} }
@@ -187,6 +195,7 @@ class CacheService {
void setCachedArtistAlbums(String cacheKey, List<Album> albums) { void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
_artistAlbumsCache[cacheKey] = albums; _artistAlbumsCache[cacheKey] = albums;
_artistAlbumsCacheTime[cacheKey] = DateTime.now(); _artistAlbumsCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_artistAlbumsCache, _artistAlbumsCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey'); _logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
} }
@@ -211,6 +220,7 @@ class CacheService {
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) { void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
_searchCache[cacheKey] = results; _searchCache[cacheKey] = results;
_searchCacheTime[cacheKey] = DateTime.now(); _searchCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_searchCache, _searchCacheTime, _maxSearchCacheSize);
_logger.log('✅ Cached search results for "$cacheKey"'); _logger.log('✅ Cached search results for "$cacheKey"');
} }
@@ -254,9 +264,20 @@ class CacheService {
/// Get cached track for a player (used for smooth swipe transitions) /// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId]; 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 /// Set cached track for a player
void setCachedTrackForPlayer(String playerId, Track? track) { void setCachedTrackForPlayer(String playerId, Track? track) {
_playerTrackCache[playerId] = 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);
} }
// ============================================================================ // ============================================================================
@@ -274,6 +295,7 @@ class CacheService {
_searchCache.clear(); _searchCache.clear();
_searchCacheTime.clear(); _searchCacheTime.clear();
_playerTrackCache.clear(); _playerTrackCache.clear();
_playerTrackCacheTime.clear();
_logger.log('🗑️ All detail caches cleared'); _logger.log('🗑️ All detail caches cleared');
} }
@@ -359,4 +381,34 @@ class CacheService {
} }
}(); }();
} }
// ============================================================================
// 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');
}
}
} }
+62
View File
@@ -239,4 +239,66 @@ class DatabaseService {
/// Clear all home row cache /// Clear all home row cache
Future<void> clearHomeRowCache() => db.clearHomeRowCache(); 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:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'debug_logger.dart'; import 'debug_logger.dart';
import 'settings_service.dart';
/// Service to generate unique per-installation player IDs /// Service to generate unique per-device player IDs
/// Simplified to match KMP client pattern: single storage key, lazy generation /// Uses stable device identifiers (ANDROID_ID) combined with MA username
/// This prevents the critical bug where multiple devices of the same model /// to create persistent player IDs that survive storage wipes and support
/// would share the same player ID and trigger playback on all devices /// multiple devices per account
class DeviceIdService { class DeviceIdService {
static const String _keyLocalPlayerId = 'local_player_id'; static const String _keyLocalPlayerId = 'local_player_id';
static final _logger = DebugLogger(); static final _logger = DebugLogger();
static const _uuid = Uuid();
/// Get or generate a unique player ID for this installation // Cache stable device ID to avoid repeated platform calls
/// ID is generated once and persisted across app restarts static String? _cachedStableDeviceId;
/// 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();
// Single source of truth - no complex migration logic /// Get stable device identifier that persists across storage wipes
final existingId = prefs.getString(_keyLocalPlayerId); /// - Android: Uses ANDROID_ID (persists until factory reset)
if (existingId != null && existingId.startsWith('ensemble_')) { /// - iOS: Uses identifierForVendor (persists until app reinstall)
_logger.log('Using existing player ID: $existingId'); static Future<String> _getStableDeviceId() async {
return existingId; if (_cachedStableDeviceId != null) {
return _cachedStableDeviceId!;
} }
// Generate new ID on first access final deviceInfo = DeviceInfoPlugin();
final playerId = 'ensemble_${_uuid.v4()}';
await prefs.setString(_keyLocalPlayerId, playerId); if (Platform.isAndroid) {
_logger.log('Generated new player ID: $playerId'); final android = await deviceInfo.androidInfo;
return playerId; // 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) /// Adopt an existing player ID (used when claiming a ghost player)
@@ -45,4 +108,12 @@ class DeviceIdService {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLocalPlayerId) == null; 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) { } catch (e) {
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: 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();
} }
} }
+49 -5
View File
@@ -1399,7 +1399,7 @@ class MusicAssistantAPI {
final result = searchResponse['result'] as Map<String, dynamic>?; final result = searchResponse['result'] as Map<String, dynamic>?;
if (result == null) { 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 // Parse results from the search response
@@ -1418,6 +1418,16 @@ class MusicAssistantAPI {
.toList() ?? .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 // Deduplicate results by name
// MA returns both library items and provider items for the same content // MA returns both library items and provider items for the same content
// Prefer library items (provider='library') as they have all provider mappings // Prefer library items (provider='library') as they have all provider mappings
@@ -1425,15 +1435,17 @@ class MusicAssistantAPI {
'artists': _deduplicateResults(artists), 'artists': _deduplicateResults(artists),
'albums': _deduplicateResults(albums), 'albums': _deduplicateResults(albums),
'tracks': _deduplicateResults(tracks), 'tracks': _deduplicateResults(tracks),
'playlists': _deduplicateResults(playlists),
'audiobooks': _deduplicateResults(audiobooks),
}; };
} catch (e) { } catch (e) {
_logger.log('Error searching: $e'); _logger.log('Error searching: $e');
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': []}; return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
} }
}, },
).catchError((e) { ).catchError((e) {
_logger.log('Error searching after retries: $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?; currentIndex = queueResult['current_index'] as int?;
shuffleEnabled = queueResult['shuffle_enabled'] as bool?; shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
repeatMode = queueResult['repeat_mode'] as String?; 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 currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
final currentItemName = currentItemData?['name'] as String?; 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 /// Play multiple tracks via queue
/// If clearQueue is true, replaces the queue (default behavior) /// If clearQueue is true, replaces the queue (default behavior)
/// If startIndex is provided, only tracks from that index onwards will be queued /// If startIndex is provided, only tracks from that index onwards will be queued
@@ -2036,11 +2068,11 @@ class MusicAssistantAPI {
} }
/// Toggle shuffle mode for queue /// Toggle shuffle mode for queue
Future<void> toggleShuffle(String queueId) async { Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
try { try {
await _sendCommand( await _sendCommand(
'player_queues/shuffle', 'player_queues/shuffle',
args: {'queue_id': queueId}, args: {'queue_id': queueId, 'shuffle_enabled': shuffleEnabled},
); );
} catch (e) { } catch (e) {
_logger.log('Error toggling shuffle: $e'); _logger.log('Error toggling shuffle: $e');
@@ -2508,6 +2540,7 @@ class MusicAssistantAPI {
} }
} }
} catch (e) { } catch (e) {
_logger.log('⚠️ Error parsing stream URI "$uri": $e');
} }
} }
@@ -2876,6 +2909,17 @@ class MusicAssistantAPI {
_updateConnectionState(MAConnectionState.disconnected); _updateConnectionState(MAConnectionState.disconnected);
await _channel?.sink.close(); await _channel?.sink.close();
_channel = null; _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(); _pendingRequests.clear();
} }
+19 -4
View File
@@ -83,6 +83,11 @@ class PcmAudioPlayer {
// Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response // Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response
static const int _feedThreshold = 5000; 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 // Stats
int _framesPlayed = 0; int _framesPlayed = 0;
int _bytesPlayed = 0; int _bytesPlayed = 0;
@@ -201,6 +206,16 @@ class PcmAudioPlayer {
return true; 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 /// Handle incoming audio data from the stream
void _onAudioData(Uint8List audioData) { void _onAudioData(Uint8List audioData) {
// Ignore data if in error state // Ignore data if in error state
@@ -215,7 +230,7 @@ class PcmAudioPlayer {
if (_state == PcmPlayerState.paused && !_isAutoRecovering) { if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery'); _logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
_isAutoRecovering = true; _isAutoRecovering = true;
_audioBuffer.add(audioData); _addToBuffer(audioData);
// Trigger async recovery // Trigger async recovery
_autoRecoverFromPause(); _autoRecoverFromPause();
@@ -224,7 +239,7 @@ class PcmAudioPlayer {
// If already recovering or in transitional state, buffer the data // If already recovering or in transitional state, buffer the data
if (_isAutoRecovering) { if (_isAutoRecovering) {
_audioBuffer.add(audioData); _addToBuffer(audioData);
return; return;
} }
@@ -232,8 +247,8 @@ class PcmAudioPlayer {
return; return;
} }
// Add to buffer // Add to buffer with overflow protection
_audioBuffer.add(audioData); _addToBuffer(audioData);
// Start playback if not already started // Start playback if not already started
if (!_isStarted && _state == PcmPlayerState.ready) { 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 double _anchorPosition = 0.0; // Position in seconds at anchor time
DateTime _anchorTime = DateTime.now(); // When we set the anchor 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 // Interpolation timer
Timer? _interpolationTimer; Timer? _interpolationTimer;
@@ -43,7 +47,13 @@ class PositionTracker {
} }
final now = DateTime.now(); 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; final interpolated = _anchorPosition + elapsed;
// Cap at duration to prevent overflow // Cap at duration to prevent overflow
@@ -61,7 +71,12 @@ class PositionTracker {
} }
final now = DateTime.now(); 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; final interpolated = _anchorPosition + elapsed;
// Cap at duration // Cap at duration
+8 -2
View File
@@ -151,7 +151,10 @@ class RecentlyPlayedService {
if (item.metadata != null) { if (item.metadata != null) {
try { try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>; 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) // Build metadata with images if available (for instant artwork display)
@@ -203,7 +206,10 @@ class RecentlyPlayedService {
if (item.metadata != null) { if (item.metadata != null) {
try { try {
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>; 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( 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');
}
}
}
+29
View File
@@ -87,6 +87,9 @@ class SendspinService {
// Auth token for proxy authentication (MA 2.7.1+) // Auth token for proxy authentication (MA 2.7.1+)
String? _authToken; String? _authToken;
// Connection deduplication guard - prevents multiple concurrent auth attempts
Completer<bool>? _connectionInProgress;
SendspinService(this.serverUrl); SendspinService(this.serverUrl);
/// Set the MA auth token for proxy authentication /// Set the MA auth token for proxy authentication
@@ -100,6 +103,13 @@ class SendspinService {
if (_isDisposed) return false; if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true; 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); _updateState(SendspinConnectionState.connecting);
try { try {
@@ -126,13 +136,19 @@ class SendspinService {
if (!connected) { if (!connected) {
_logger.log('Sendspin: External proxy connection failed'); _logger.log('Sendspin: External proxy connection failed');
_updateState(SendspinConnectionState.error); _updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false; return false;
} }
_connectionInProgress?.complete(true);
_connectionInProgress = null;
return true; return true;
} catch (e) { } catch (e) {
_logger.log('Sendspin: Connection error: $e'); _logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error); _updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false; return false;
} }
} }
@@ -142,6 +158,13 @@ class SendspinService {
if (_isDisposed) return false; if (_isDisposed) return false;
if (_state == SendspinConnectionState.connected) return true; 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); _updateState(SendspinConnectionState.connecting);
try { try {
@@ -154,13 +177,19 @@ class SendspinService {
if (!connected) { if (!connected) {
_updateState(SendspinConnectionState.error); _updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false; return false;
} }
_connectionInProgress?.complete(true);
_connectionInProgress = null;
return true; return true;
} catch (e) { } catch (e) {
_logger.log('Sendspin: Connection error: $e'); _logger.log('Sendspin: Connection error: $e');
_updateState(SendspinConnectionState.error); _updateState(SendspinConnectionState.error);
_connectionInProgress?.complete(false);
_connectionInProgress = null;
return false; return false;
} }
} }
+35 -54
View File
@@ -1,5 +1,7 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert'; import 'dart:convert';
import 'device_id_service.dart';
import 'secure_storage_service.dart';
class SettingsService { class SettingsService {
static const String _keyServerUrl = 'server_url'; static const String _keyServerUrl = 'server_url';
@@ -126,66 +128,44 @@ class SettingsService {
} }
} }
// Get authentication token for stream requests // Get authentication token for stream requests (securely stored)
static Future<String?> getAuthToken() async { static Future<String?> getAuthToken() async {
final prefs = await SharedPreferences.getInstance(); return await SecureStorageService.getAuthToken();
return prefs.getString(_keyAuthToken);
} }
// Set authentication token for stream requests // Set authentication token for stream requests (securely stored)
static Future<void> setAuthToken(String? token) async { static Future<void> setAuthToken(String? token) async {
final prefs = await SharedPreferences.getInstance(); await SecureStorageService.setAuthToken(token);
if (token == null || token.isEmpty) {
await prefs.remove(_keyAuthToken);
} else {
await prefs.setString(_keyAuthToken, 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 { static Future<String?> getMaAuthToken() async {
final prefs = await SharedPreferences.getInstance(); return await SecureStorageService.getMaAuthToken();
return prefs.getString(_keyMaAuthToken);
} }
// 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 { static Future<void> setMaAuthToken(String? token) async {
final prefs = await SharedPreferences.getInstance(); await SecureStorageService.setMaAuthToken(token);
if (token == null || token.isEmpty) {
await prefs.remove(_keyMaAuthToken);
} else {
await prefs.setString(_keyMaAuthToken, token);
}
} }
// Clear Music Assistant native auth token // Clear Music Assistant native auth token
static Future<void> clearMaAuthToken() async { static Future<void> clearMaAuthToken() async {
final prefs = await SharedPreferences.getInstance(); await SecureStorageService.clearMaAuthToken();
await prefs.remove(_keyMaAuthToken);
} }
// Get authentication credentials (serialized auth strategy credentials) // Get authentication credentials (serialized auth strategy credentials, securely stored)
static Future<Map<String, dynamic>?> getAuthCredentials() async { static Future<Map<String, dynamic>?> getAuthCredentials() async {
final prefs = await SharedPreferences.getInstance(); return await SecureStorageService.getAuthCredentials();
final json = prefs.getString(_keyAuthCredentials);
if (json == null) return null;
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (e) {
return null;
}
} }
// 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 { static Future<void> setAuthCredentials(Map<String, dynamic> credentials) async {
final prefs = await SharedPreferences.getInstance(); await SecureStorageService.setAuthCredentials(credentials);
await prefs.setString(_keyAuthCredentials, jsonEncode(credentials));
} }
// Clear authentication credentials // Clear authentication credentials
static Future<void> clearAuthCredentials() async { static Future<void> clearAuthCredentials() async {
final prefs = await SharedPreferences.getInstance(); await SecureStorageService.clearAuthCredentials();
await prefs.remove(_keyAuthCredentials);
} }
// Get username for authentication // Get username for authentication
@@ -204,20 +184,14 @@ class SettingsService {
} }
} }
// Get password for authentication // Get password for authentication (securely stored)
static Future<String?> getPassword() async { static Future<String?> getPassword() async {
final prefs = await SharedPreferences.getInstance(); return await SecureStorageService.getPassword();
return prefs.getString(_keyPassword);
} }
// Set password for authentication // Set password for authentication (securely stored)
static Future<void> setPassword(String? password) async { static Future<void> setPassword(String? password) async {
final prefs = await SharedPreferences.getInstance(); await SecureStorageService.setPassword(password);
if (password == null || password.isEmpty) {
await prefs.remove(_keyPassword);
} else {
await prefs.setString(_keyPassword, password);
}
} }
// Get built-in player ID (persistent UUID for this device) // Get built-in player ID (persistent UUID for this device)
@@ -394,18 +368,27 @@ class SettingsService {
} }
// Helper to create player name with possessive apostrophe // Helper to create player name with possessive apostrophe
// Automatically detects Phone vs Tablet based on screen size
static String _makePlayerName(String ownerName) { 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')) { if (ownerName.toLowerCase().endsWith('s')) {
return "$ownerName' Phone"; return "$ownerName' $deviceType";
} else { } else {
return "$ownerName's Phone"; return "$ownerName's $deviceType";
} }
} }
static Future<void> clearSettings() async { static Future<void> clearSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.clear(); 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) // Home Screen Row Settings (Main rows - default on)
@@ -671,13 +654,11 @@ class SettingsService {
} }
static Future<String?> getAbsApiToken() async { static Future<String?> getAbsApiToken() async {
final prefs = await SharedPreferences.getInstance(); return await SecureStorageService.getAbsApiToken();
return prefs.getString(_keyAbsApiToken);
} }
static Future<void> setAbsApiToken(String token) async { static Future<void> setAbsApiToken(String token) async {
final prefs = await SharedPreferences.getInstance(); await SecureStorageService.setAbsApiToken(token);
await prefs.setString(_keyAbsApiToken, token);
} }
static Future<bool> getAbsEnabled() async { static Future<bool> getAbsEnabled() async {
@@ -693,8 +674,8 @@ class SettingsService {
static Future<void> clearAbsSettings() async { static Future<void> clearAbsSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyAbsServerUrl); await prefs.remove(_keyAbsServerUrl);
await prefs.remove(_keyAbsApiToken);
await prefs.remove(_keyAbsEnabled); await prefs.remove(_keyAbsEnabled);
await SecureStorageService.setAbsApiToken(null); // Clear from secure storage
} }
// Audiobookshelf Library Settings (via MA browse API) // Audiobookshelf Library Settings (via MA browse API)
+257 -30
View File
@@ -77,10 +77,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null); final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
// Dimensions // 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 _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar)
static const double _collapsedBorderRadius = 16.0; 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 _bottomNavHeight = 56.0;
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
@@ -104,6 +104,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// When true, we hide main content and show peek content at center // When true, we hide main content and show peek content at center
bool _inTransition = false; 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 // Track favorite state for current track
bool _isCurrentTrackFavorite = false; bool _isCurrentTrackFavorite = false;
String? _lastTrackUri; // Track which track we last checked favorite status for String? _lastTrackUri; // Track which track we last checked favorite status for
@@ -345,14 +351,17 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
} }
// 1. Show cached queue immediately (if available) // 1. Show cached queue immediately (if available)
final cachedQueue = await maProvider.getCachedQueue(player.playerId); // Only load from cache if we don't already have a queue (preserves optimistic updates)
if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) { if (_queue == null) {
setState(() { final cachedQueue = await maProvider.getCachedQueue(player.playerId);
_queue = cachedQueue; if (cachedQueue != null && cachedQueue.items.isNotEmpty && mounted) {
_isLoadingQueue = false; setState(() {
}); _queue = cachedQueue;
} else { _isLoadingQueue = false;
setState(() => _isLoadingQueue = true); });
} else {
setState(() => _isLoadingQueue = true);
}
} }
// 2. Fetch fresh queue in background // 2. Fetch fresh queue in background
@@ -360,11 +369,28 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
try { try {
final freshQueue = await maProvider.getQueue(player.playerId); final freshQueue = await maProvider.getQueue(player.playerId);
if (mounted && freshQueue != null) { 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 || final queueChanged = _queue == null ||
_queue!.items.length != freshQueue.items.length || _queue!.items.length != freshQueue.items.length ||
_queue!.currentIndex != freshQueue.currentIndex; _queue!.currentIndex != freshQueue.currentIndex ||
_queue!.shuffle != freshQueue.shuffle ||
itemsReordered;
if (queueChanged) { if (queueChanged) {
debugPrint('🔀 Queue changed, updating UI (reordered=$itemsReordered)');
setState(() { setState(() {
_queue = freshQueue; _queue = freshQueue;
_isLoadingQueue = false; _isLoadingQueue = false;
@@ -406,16 +432,69 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
} }
Future<void> _toggleShuffle() async { 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>(); 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(); await _loadQueue();
} }
Future<void> _cycleRepeat() async { Future<void> _cycleRepeat() async {
if (_queue == null) return; 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>(); final maProvider = context.read<MusicAssistantProvider>();
await maProvider.cycleRepeatMode(_queue!.playerId, _queue!.repeatMode); await maProvider.setRepeatMode(_queue!.playerId, nextMode);
await _loadQueue(); await _loadQueue();
} }
@@ -1022,6 +1101,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
right: _collapsedMargin, right: _collapsedMargin,
bottom: adjustedBottomOffset, bottom: adjustedBottomOffset,
child: GestureDetector( child: GestureDetector(
// Tap to dismiss player reveal (matching _buildMorphingPlayer behavior)
onTap: widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : null,
onVerticalDragUpdate: (details) { onVerticalDragUpdate: (details) {
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) { if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
widget.onRevealPlayers!(); widget.onRevealPlayers!();
@@ -1163,7 +1244,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedArtLeft = (screenSize.width - expandedArtSize) / 2; final expandedArtLeft = (screenSize.width - expandedArtSize) / 2;
final artLeft = _lerpDouble(collapsedArtLeft, expandedArtLeft, t); final artLeft = _lerpDouble(collapsedArtLeft, expandedArtLeft, t);
final collapsedArtTop = 0.0; final collapsedArtTop = (_collapsedHeight - _collapsedArtSize) / 2; // Center vertically
final expandedArtTop = topPadding + headerHeight + 16; final expandedArtTop = topPadding + headerHeight + 16;
final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t); final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t);
@@ -1239,6 +1320,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap; final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t); final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
// Player name - only visible when collapsed (third line)
final playerNameTop = MiniPlayerLayout.tertiaryTop;
// Album - subtle, below artist // Album - subtle, below artist
final expandedAlbumTop = expandedArtistTop + artistHeight + artistToAlbumGap; final expandedAlbumTop = expandedArtistTop + artistHeight + artistToAlbumGap;
@@ -1277,8 +1361,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: GestureDetector( child: GestureDetector(
// Use translucent to allow child widgets (like buttons) to receive taps // Use translucent to allow child widgets (like buttons) to receive taps
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
// Only handle tap when collapsed - when expanded, let children handle their own taps // Handle tap: when device list is visible, dismiss it; when collapsed, expand
onTap: isExpanded ? null : expand, onTap: isExpanded ? null : (widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : expand),
onVerticalDragStart: (details) { onVerticalDragStart: (details) {
// For expanded player or queue panel: start tracking immediately // For expanded player or queue panel: start tracking immediately
// For collapsed player: defer decision until we know swipe direction // For collapsed player: defer decision until we know swipe direction
@@ -1322,9 +1406,39 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
}, },
onHorizontalDragStart: (details) { onHorizontalDragStart: (details) {
_horizontalDragStartX = details.globalPosition.dx; _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) { 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; if (isExpanded || !hasMultiplePlayers) return;
// Ignore drags that started in the edge dead zone // Ignore drags that started in the edge dead zone
@@ -1337,6 +1451,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_handleHorizontalDragUpdate(details, maProvider, collapsedWidth); _handleHorizontalDragUpdate(details, maProvider, collapsedWidth);
}, },
onHorizontalDragEnd: (details) { 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) // Ignore swipes that started near the edges (Android back gesture zone)
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final startedInDeadZone = _horizontalDragStartX != null && final startedInDeadZone = _horizontalDragStartX != null &&
@@ -1361,12 +1487,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
} }
}, },
child: Container( child: Container(
decoration: BoxDecoration( // Use foregroundDecoration for border so it renders ON TOP of content
borderRadius: BorderRadius.circular(borderRadius), // This prevents the album art from clipping the yellow synced border
border: selectedPlayer.isGrouped && t < 0.5 foregroundDecoration: maProvider.isPlayerManuallySynced(selectedPlayer.playerId) && t < 0.5
? Border.all(color: _groupBorderColor, width: 1.5) ? BoxDecoration(
: null, borderRadius: BorderRadius.circular(borderRadius),
), border: Border.all(color: _groupBorderColor, width: 1.5),
)
: null,
child: Material( child: Material(
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
@@ -1536,10 +1664,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
child: SizedBox( child: SizedBox(
width: titleWidth, width: titleWidth,
child: Text( child: Text(
// Show player name when device reveal visible and collapsed currentTrack.name,
(widget.isDeviceRevealVisible && t < 0.5)
? selectedPlayer.name
: currentTrack.name,
style: TextStyle( style: TextStyle(
color: textColor, color: textColor,
fontSize: titleFontSize, fontSize: titleFontSize,
@@ -1586,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) // Album name OR Chapter name (expanded only)
// GPU PERF: Use color alpha instead of Opacity widget // GPU PERF: Use color alpha instead of Opacity widget
// For audiobooks: show current chapter; for music: show album // For audiobooks: show current chapter; for music: show album
@@ -1755,7 +1900,60 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
t: t, t: t,
expandedElementsOpacity: expandedElementsOpacity, 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, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end, mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
children: [ children: [
@@ -1946,6 +2144,32 @@ 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),
),
),
],
),
),
),
], ],
), ),
), ),
@@ -1983,12 +2207,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final hasTrack = _peekTrack != null && peekImageUrl != null; final hasTrack = _peekTrack != null && peekImageUrl != null;
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown); final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice; 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( return Transform.translate(
offset: Offset(peekBaseOffset, 0), offset: Offset(peekBaseOffset, 0),
child: MiniPlayerContent( child: MiniPlayerContent(
primaryText: peekTrackName, primaryText: peekTrackName,
secondaryText: peekArtistName, secondaryText: peekArtistName,
tertiaryText: peekPlayerName,
imageUrl: hasTrack ? peekImageUrl : null, imageUrl: hasTrack ? peekImageUrl : null,
playerName: peekPlayer?.name ?? '', playerName: peekPlayer?.name ?? '',
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
+121 -37
View File
@@ -9,6 +9,7 @@ import '../providers/navigation_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import 'expandable_player.dart'; import 'expandable_player.dart';
import 'player/mini_player_content.dart' show MiniPlayerLayout;
import 'player/player_reveal_overlay.dart'; import 'player/player_reveal_overlay.dart';
/// Cached color with contrast adjustment /// Cached color with contrast adjustment
@@ -56,14 +57,14 @@ class BottomSpacing {
/// Height of the bottom navigation bar /// Height of the bottom navigation bar
static const double navBarHeight = 56.0; static const double navBarHeight = 56.0;
/// Height of mini player when visible (64px height + 12px margin) /// Height of mini player when visible (height + 12px margin)
static const double miniPlayerHeight = 76.0; static double get miniPlayerHeight => MiniPlayerLayout.height + 12.0;
/// Space needed when only nav bar is visible (with some extra padding) /// Space needed when only nav bar is visible (with some extra padding)
static const double navBarOnly = navBarHeight + 16.0; static const double navBarOnly = navBarHeight + 16.0;
/// Space needed when mini player is also visible /// 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 /// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
@@ -167,9 +168,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
final _bounceOffsetNotifier = ValueNotifier<double>(0.0); final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
// Hint system state // 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 _showHints = true;
bool _hasCompletedOnboarding = false; // First-use welcome screen bool _hasCompletedOnboarding = false; // First-use welcome screen
bool _hintTriggered = false; // Prevent multiple triggers per session 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); final _hintOpacityNotifier = ValueNotifier<double>(0.0);
// Welcome content fade-in animation // Welcome content fade-in animation
@@ -234,12 +241,66 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
Future<void> _loadHintSettings() async { Future<void> _loadHintSettings() async {
_showHints = await SettingsService.getShowHints(); _showHints = await SettingsService.getShowHints();
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding(); _hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
// Show welcome screen only on first use (not completed onboarding yet) if (!mounted) return;
if (!_hasCompletedOnboarding && mounted && !_hintTriggered) {
_startWelcomeScreen(); 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 /// Start the welcome screen with fade-in animation
void _startWelcomeScreen() { void _startWelcomeScreen() {
if (_hintTriggered) return; if (_hintTriggered) return;
@@ -250,24 +311,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
_isHintModeActive = true; _isHintModeActive = true;
}); });
// Fade in welcome content // Fade in welcome content (logo + title)
_welcomeFadeController.forward(); _welcomeFadeController.forward();
// Start bounce animation after fade completes // Mini player bounce is started separately once connected via _startMiniPlayerBounce()
_welcomeFadeController.addStatusListener((status) {
if (status == AnimationStatus.completed && mounted) {
_doubleBounceController.reset();
_doubleBounceController.forward();
// Repeat bounce every 2 seconds
_hintBounceTimer = Timer.periodic(const Duration(seconds: 2), (_) {
if (_isHintModeActive && mounted) {
_doubleBounceController.reset();
_doubleBounceController.forward();
}
});
}
});
} }
@override @override
@@ -362,6 +409,36 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
// 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 // Handle back gesture at top level - dismiss hint mode or device list if visible
return PopScope( return PopScope(
canPop: !_isRevealVisible && !_isHintModeActive, canPop: !_isRevealVisible && !_isHintModeActive,
@@ -383,14 +460,18 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
child: widget.child, child: widget.child,
), ),
// Global persistent bottom navigation bar - positioned at bottom // Global persistent bottom navigation bar - positioned at bottom
Positioned( // Hide when: not connected, no player selected, OR showing welcome screen
left: 0, // For first-time users, selectedPlayer becomes non-null at the same time
right: 0, // as shouldShowWelcomeBackdrop becomes true, preventing any gap.
bottom: 0, if (provider.isConnected && provider.selectedPlayer != null && !shouldShowWelcomeBackdrop)
child: ListenableBuilder( Positioned(
listenable: navigationProvider, left: 0,
builder: (context, _) { right: 0,
return Consumer<ThemeProvider>( bottom: 0,
child: ListenableBuilder(
listenable: navigationProvider,
builder: (context, _) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) { builder: (context, themeProvider, _) {
// Use adaptive primary color for bottom nav when adaptive theme is enabled // Use adaptive primary color for bottom nav when adaptive theme is enabled
final sourceColor = themeProvider.adaptiveTheme final sourceColor = themeProvider.adaptiveTheme
@@ -479,9 +560,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
); );
}, },
); );
}, },
),
), ),
),
// Blur backdrop for device selector (reveal mode) - static, no animation // Blur backdrop for device selector (reveal mode) - static, no animation
if (_isRevealVisible && !_isHintModeActive) if (_isRevealVisible && !_isHintModeActive)
Positioned.fill( Positioned.fill(
@@ -497,8 +578,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
), ),
), ),
// Blur backdrop for hint/welcome mode - animated fade from solid to semi-transparent // Welcome/hint backdrop - shows IMMEDIATELY when first-time user is ready.
if (_isHintModeActive) // Uses computed `shouldShowWelcomeBackdrop` which is true BEFORE _isHintModeActive,
// preventing the 1-frame gap where home screen would be visible.
if (shouldShowWelcomeBackdrop)
Positioned.fill( Positioned.fill(
child: TweenAnimationBuilder<double>( child: TweenAnimationBuilder<double>(
// Hold solid for 2s, then fade to 0.5 over 1s // Hold solid for 2s, then fade to 0.5 over 1s
@@ -509,7 +592,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
return BackdropFilter( return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0), filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container( child: Container(
color: colorScheme.surface.withOpacity(opacity), // Use same dark color as connection screen for seamless transition
color: const Color(0xFF1a1a1a).withOpacity(opacity),
), ),
); );
}, },
@@ -553,8 +637,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
), ),
), ),
// Bottom section: Hint text and Skip button (positioned relative to mini player) // Bottom section: Hint text and Skip button (only show once mini player is visible)
if (_isHintModeActive) if (_isHintModeActive && _miniPlayerHintsReady)
Positioned( Positioned(
left: 24, left: 24,
right: 24, right: 24,
+42 -9
View File
@@ -3,18 +3,24 @@ import 'package:cached_network_image/cached_network_image.dart';
/// Shared constants for mini player layout /// Shared constants for mini player layout
class MiniPlayerLayout { class MiniPlayerLayout {
static const double height = 64.0; static const double height = 72.0;
static const double artSize = 64.0; static const double artSize = 72.0;
static const double textLeftOffset = 10.0; // Gap between art and text static const double textLeftOffset = 10.0; // Gap between art and text
static const double textLeft = artSize + textLeftOffset; // 74px static const double textLeft = artSize + textLeftOffset; // 82px
static const double primaryTop = 13.0; // 3-line layout: track, artist, player
static const double secondaryTop = 33.0; 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 textRightPadding = 12.0;
static const double iconSize = 28.0; static const double iconSize = 28.0;
static const double iconOpacity = 0.4; static const double iconOpacity = 0.4;
static const double secondaryTextOpacity = 0.6; static const double secondaryTextOpacity = 0.6;
static const double primaryFontSize = 16.0; static const double primaryFontSize = 16.0;
static const double secondaryFontSize = 14.0; static const double secondaryFontSize = 14.0;
static const double tertiaryFontSize = 14.0;
static const FontWeight primaryFontWeight = FontWeight.w500; static const FontWeight primaryFontWeight = FontWeight.w500;
} }
@@ -30,6 +36,10 @@ class MiniPlayerContent extends StatelessWidget {
/// If null, primary text will be vertically centered /// If null, primary text will be vertically centered
final String? secondaryText; 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 /// Album art URL - if null, shows device icon
final String? imageUrl; final String? imageUrl;
@@ -61,6 +71,7 @@ class MiniPlayerContent extends StatelessWidget {
super.key, super.key,
required this.primaryText, required this.primaryText,
this.secondaryText, this.secondaryText,
this.tertiaryText,
this.imageUrl, this.imageUrl,
required this.playerName, required this.playerName,
required this.backgroundColor, required this.backgroundColor,
@@ -75,8 +86,13 @@ class MiniPlayerContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty; final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
final slidePixels = slideOffset * width; 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) // Calculate text width (leaves room for right padding)
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding; final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
@@ -99,10 +115,10 @@ class MiniPlayerContent extends StatelessWidget {
child: Container(color: backgroundColor), child: Container(color: backgroundColor),
), ),
// Art / Icon area // Art / Icon area - centered vertically
Positioned( Positioned(
left: slidePixels, left: slidePixels,
top: 0, top: (MiniPlayerLayout.height - MiniPlayerLayout.artSize) / 2,
child: SizedBox( child: SizedBox(
width: MiniPlayerLayout.artSize, width: MiniPlayerLayout.artSize,
height: MiniPlayerLayout.artSize, height: MiniPlayerLayout.artSize,
@@ -125,7 +141,7 @@ class MiniPlayerContent extends StatelessWidget {
Positioned( Positioned(
left: MiniPlayerLayout.textLeft + slidePixels, left: MiniPlayerLayout.textLeft + slidePixels,
top: hasSecondaryLine top: hasSecondaryLine
? MiniPlayerLayout.primaryTop ? primaryTop
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2, : (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
right: MiniPlayerLayout.textRightPadding - slidePixels, right: MiniPlayerLayout.textRightPadding - slidePixels,
child: Text( child: Text(
@@ -144,7 +160,7 @@ class MiniPlayerContent extends StatelessWidget {
if (hasSecondaryLine) if (hasSecondaryLine)
Positioned( Positioned(
left: MiniPlayerLayout.textLeft + slidePixels, left: MiniPlayerLayout.textLeft + slidePixels,
top: MiniPlayerLayout.secondaryTop, top: secondaryTop,
right: MiniPlayerLayout.textRightPadding - slidePixels, right: MiniPlayerLayout.textRightPadding - slidePixels,
child: isHint child: isHint
? Row( ? Row(
@@ -179,6 +195,23 @@ class MiniPlayerContent extends StatelessWidget {
overflow: TextOverflow.ellipsis, 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(
tertiaryText!,
style: TextStyle(
color: textColor,
fontSize: MiniPlayerLayout.tertiaryFontSize,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
], ],
), ),
); );
+273 -139
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../../providers/music_assistant_provider.dart'; import '../../providers/music_assistant_provider.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
@@ -6,7 +7,8 @@ import '../../l10n/app_localizations.dart';
/// A player card that matches the mini player's visual style. /// 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. /// 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 player;
final dynamic trackInfo; final dynamic trackInfo;
final String? albumArtUrl; final String? albumArtUrl;
@@ -20,6 +22,7 @@ class PlayerCard extends StatelessWidget {
final VoidCallback? onPlayPause; final VoidCallback? onPlayPause;
final VoidCallback? onSkipNext; final VoidCallback? onSkipNext;
final VoidCallback? onPower; final VoidCallback? onPower;
final ValueChanged<double>? onVolumeChange;
// Pastel yellow for grouped players // Pastel yellow for grouped players
static const Color groupBorderColor = Color(0xFFFFF59D); static const Color groupBorderColor = Color(0xFFFFF59D);
@@ -39,174 +42,305 @@ class PlayerCard extends StatelessWidget {
this.onPlayPause, this.onPlayPause,
this.onSkipNext, this.onSkipNext,
this.onPower, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Match mini player dimensions // Match mini player dimensions
const double cardHeight = Dimensions.miniPlayerHeight; // 64px const double cardHeight = Dimensions.miniPlayerHeight;
const double artSize = cardHeight; const double artSize = cardHeight;
const double borderRadius = Radii.xl; // 16px - same as mini player const double borderRadius = Radii.xl; // 16px - same as mini player
return GestureDetector( // Colors for volume overlay (same as mini player progress bar)
onTap: onTap, final filledColor = widget.backgroundColor;
onLongPress: onLongPress, final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
child: Container(
height: cardHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
border: isGrouped
? Border.all(color: groupBorderColor, width: 1.5)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
// Album art or speaker icon - same size for consistent text alignment
SizedBox(
width: artSize,
height: artSize,
child: albumArtUrl != null
? CachedNetworkImage(
imageUrl: albumArtUrl!,
fit: BoxFit.cover,
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => _buildSpeakerIcon(),
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
)
: _buildSpeakerIcon(),
),
// Player info return LayoutBuilder(
Expanded( builder: (context, constraints) {
child: Padding( _cardWidth = constraints.maxWidth;
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column( return GestureDetector(
mainAxisAlignment: MainAxisAlignment.center, onTap: _isDraggingVolume ? null : widget.onTap,
crossAxisAlignment: CrossAxisAlignment.start, onLongPress: _isDraggingVolume ? null : widget.onLongPress,
children: [ onHorizontalDragStart: _onDragStart,
// Player name onHorizontalDragUpdate: _onDragUpdate,
Row( onHorizontalDragEnd: _onDragEnd,
children: [ onHorizontalDragCancel: _onDragCancel,
// Status indicator // Use Stack to render border ON TOP of content, preventing album art clipping
Container( child: Stack(
width: 8, children: [
height: 8, // Main card content (hidden when dragging volume)
margin: const EdgeInsets.only(right: 8), if (!_isDraggingVolume)
decoration: BoxDecoration( _buildCardContent(cardHeight, artSize, borderRadius),
shape: BoxShape.circle,
color: _getStatusColor(), // Volume overlay (shown when dragging)
), if (_isDraggingVolume)
), Container(
Expanded( height: cardHeight,
child: Text( decoration: BoxDecoration(
player.name, color: unfilledColor,
style: TextStyle( borderRadius: BorderRadius.circular(borderRadius),
color: textColor, boxShadow: [
fontSize: 16, BoxShadow(
fontWeight: FontWeight.w500, color: Colors.black.withOpacity(0.2),
fontFamily: 'Roboto', blurRadius: 8,
decoration: TextDecoration.none, offset: const Offset(0, 2),
), ),
maxLines: 1, ],
overflow: TextOverflow.ellipsis, ),
), clipBehavior: Clip.antiAlias,
), child: Align(
], alignment: Alignment.centerLeft,
), child: FractionallySizedBox(
const SizedBox(height: 2), widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
// Track name or status heightFactor: 1.0,
Text( child: Container(
_getSubtitle(context), decoration: BoxDecoration(
style: TextStyle( color: filledColor,
color: textColor.withOpacity(0.6), borderRadius: BorderRadius.only(
fontSize: 14, topLeft: Radius.circular(borderRadius),
fontFamily: 'Roboto', bottomLeft: Radius.circular(borderRadius),
decoration: TextDecoration.none, ),
),
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
],
),
),
),
// Transport controls - compact sizing to align with mini player
// Play/Pause and Next only shown when powered with content
if (player.available && player.powered && trackInfo != null) ...[
// Play/Pause - slight nudge right
Transform.translate(
offset: const Offset(3, 0),
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor,
size: 28,
),
onPressed: onPlayPause,
padding: EdgeInsets.zero,
), ),
), ),
// 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( child: IconButton(
icon: Icon( icon: Icon(
Icons.skip_next_rounded, widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: textColor, color: widget.textColor,
size: 28, size: 28,
), ),
onPressed: onSkipNext, onPressed: widget.onPlayPause,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
), ),
), ),
], ),
// Power button - smallest // Skip next - nudged right to close gap with power
if (player.available) Transform.translate(
IconButton( offset: const Offset(6, 0),
child: IconButton(
icon: Icon( icon: Icon(
Icons.power_settings_new_rounded, Icons.skip_next_rounded,
color: player.powered ? textColor : textColor.withOpacity(0.5), color: widget.textColor,
size: 20, size: 28,
), ),
onPressed: onPower, onPressed: widget.onSkipNext,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), 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() { Widget _buildSpeakerIcon() {
// Darker shade of the card background to match album art square // 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( return Container(
color: iconBgColor, color: iconBgColor,
child: Center( child: Center(
child: Icon( child: Icon(
Icons.speaker_rounded, Icons.speaker_rounded,
color: textColor.withOpacity(0.4), color: widget.textColor.withOpacity(0.4),
size: 28, size: 28,
), ),
), ),
@@ -214,30 +348,30 @@ class PlayerCard extends StatelessWidget {
} }
Color _getStatusColor() { Color _getStatusColor() {
if (!player.available) { if (!widget.player.available) {
return Colors.grey.withOpacity(0.5); return Colors.grey.withOpacity(0.5);
} }
if (!player.powered) { if (!widget.player.powered) {
return Colors.grey; return Colors.grey;
} }
if (isPlaying) { if (widget.isPlaying) {
return Colors.green; return Colors.green;
} }
if (trackInfo != null) { if (widget.trackInfo != null) {
return Colors.orange; // Has content but paused return Colors.orange; // Has content but paused
} }
return Colors.grey.shade400; // Idle return Colors.grey.shade400; // Idle
} }
String _getSubtitle(BuildContext context) { String _getSubtitle(BuildContext context) {
if (!player.available) { if (!widget.player.available) {
return S.of(context)!.playerStateUnavailable; return S.of(context)!.playerStateUnavailable;
} }
if (!player.powered) { if (!widget.player.powered) {
return S.of(context)!.playerStateOff; return S.of(context)!.playerStateOff;
} }
if (trackInfo != null) { if (widget.trackInfo != null) {
return trackInfo.name ?? S.of(context)!.playerStateIdle; return widget.trackInfo.name ?? S.of(context)!.playerStateIdle;
} }
return 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) if (isExpanded && expandedElementsOpacity > 0.1)
_buildSecondaryButton( _buildSecondaryButton(
icon: Icons.shuffle_rounded, icon: Icons.shuffle_rounded,
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5)) // Fix: compute final opacity once to avoid multiplicative opacity bug
.withOpacity(expandedElementsOpacity), color: shuffle == true
? primaryColor.withOpacity(expandedElementsOpacity)
: textColor.withOpacity(0.5 * expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onToggleShuffle, onPressed: isLoadingQueue ? null : onToggleShuffle,
), ),
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)), if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
// Previous // Previous
_buildControlButton( Semantics(
icon: Icons.skip_previous_rounded, button: true,
color: textColor, label: 'Previous track',
size: skipButtonSize, child: _buildControlButton(
onPressed: onPrevious, icon: Icons.skip_previous_rounded,
useAnimation: isExpanded, color: textColor,
size: skipButtonSize,
onPressed: onPrevious,
useAnimation: isExpanded,
),
), ),
SizedBox(width: _lerpDouble(0, 20, t)), SizedBox(width: _lerpDouble(0, 20, t)),
@@ -80,12 +86,16 @@ class PlayerControls extends StatelessWidget {
SizedBox(width: _lerpDouble(0, 20, t)), SizedBox(width: _lerpDouble(0, 20, t)),
// Next // Next
_buildControlButton( Semantics(
icon: Icons.skip_next_rounded, button: true,
color: textColor, label: 'Next track',
size: skipButtonSize, child: _buildControlButton(
onPressed: onNext, icon: Icons.skip_next_rounded,
useAnimation: isExpanded, color: textColor,
size: skipButtonSize,
onPressed: onNext,
useAnimation: isExpanded,
),
), ),
// Repeat (expanded only) // Repeat (expanded only)
@@ -94,10 +104,10 @@ class PlayerControls extends StatelessWidget {
if (isExpanded && expandedElementsOpacity > 0.1) if (isExpanded && expandedElementsOpacity > 0.1)
_buildSecondaryButton( _buildSecondaryButton(
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded, icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
color: (repeatMode != null && repeatMode != 'off' // Fix: compute final opacity once to avoid multiplicative opacity bug
? primaryColor color: (repeatMode != null && repeatMode != 'off')
: textColor.withOpacity(0.5)) ? primaryColor.withOpacity(expandedElementsOpacity)
.withOpacity(expandedElementsOpacity), : textColor.withOpacity(0.5 * expandedElementsOpacity),
onPressed: isLoadingQueue ? null : onCycleRepeat, onPressed: isLoadingQueue ? null : onCycleRepeat,
), ),
], ],
@@ -151,21 +161,26 @@ class PlayerControls extends StatelessWidget {
final bgColor = Color.lerp(Colors.transparent, primaryColor, progress); final bgColor = Color.lerp(Colors.transparent, primaryColor, progress);
final iconColor = Color.lerp(textColor, backgroundColor, progress); final iconColor = Color.lerp(textColor, backgroundColor, progress);
return GestureDetector( return Semantics(
onLongPress: onStop, button: true,
child: Container( label: isPlaying ? 'Pause' : 'Play',
width: playButtonContainerSize, hint: 'Long press to stop',
height: playButtonContainerSize, child: GestureDetector(
decoration: BoxDecoration( onLongPress: onStop,
color: bgColor, child: Container(
shape: BoxShape.circle, width: playButtonContainerSize,
), height: playButtonContainerSize,
child: IconButton( decoration: BoxDecoration(
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded), color: bgColor,
color: iconColor, shape: BoxShape.circle,
iconSize: playButtonSize, ),
onPressed: onPlayPause, child: IconButton(
padding: EdgeInsets.zero, icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
color: iconColor,
iconSize: playButtonSize,
onPressed: onPlayPause,
padding: EdgeInsets.zero,
),
), ),
), ),
); );
+160 -27
View File
@@ -44,6 +44,10 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
double _dragOffset = 0; double _dragOffset = 0;
bool _isDragging = false; 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 // Cache extracted colors per player for per-device accent colors
final Map<String, ColorScheme?> _playerColors = {}; final Map<String, ColorScheme?> _playerColors = {};
@@ -128,6 +132,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
void dispose() { void dispose() {
_revealController.dispose(); _revealController.dispose();
_refreshTimer?.cancel(); _refreshTimer?.cancel();
_scrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -157,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) { 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(() { setState(() {
_isDragging = true; _isDragging = true;
// Negate delta so swipe down = cards move down (toward dismissal) _dragOffset += delta;
_dragOffset -= details.primaryDelta ?? 0;
// Clamp: allow moving down (positive) freely, limit moving up to 20px
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
}); });
} }
void _handleVerticalDragEnd(DragEndDetails details) { void _handleVerticalDragEnd(DragEndDetails details) {
final velocity = details.primaryVelocity ?? 0; 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 // Dismiss if dragged down enough or with enough velocity
if (_dragOffset > 100 || velocity > 500) { if (_dragOffset > 100 || velocity > 500) {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
@@ -215,6 +252,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
const cardSpacing = 12.0; const cardSpacing = 12.0;
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing; 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( return AnimatedBuilder(
animation: _revealAnimation, animation: _revealAnimation,
builder: (context, child) { builder: (context, child) {
@@ -255,9 +307,28 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
// Show different hint for onboarding vs regular use // Show different hint for onboarding vs regular use
final isOnboarding = widget.showOnboardingHints; final isOnboarding = widget.showOnboardingHints;
final hintText = isOnboarding
? S.of(context)!.selectPlayerHint Widget buildHintRow(IconData icon, String text) {
: S.of(context)!.holdToSync; 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( return Transform.translate(
offset: Offset(0, hintSlideOffset), offset: Offset(0, hintSlideOffset),
@@ -265,31 +336,92 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Material( child: Material(
type: MaterialType.transparency, type: MaterialType.transparency,
child: Row( child: isOnboarding
mainAxisSize: MainAxisSize.min, ? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
children: [ : Column(
Icon( mainAxisSize: MainAxisSize.min,
isOnboarding ? Icons.touch_app_outlined : Icons.lightbulb_outline, children: [
size: 18, buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
color: colorScheme.onSurface.withOpacity(0.7), const SizedBox(height: 4),
), buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
const SizedBox(width: 6), ],
Text(
hintText,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.7),
fontSize: 16,
fontWeight: FontWeight.w500,
), ),
),
],
),
), ),
), ),
); );
}, },
), ),
// Build player cards - all slide from behind mini player // 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) { ...List.generate(players.length, (index) {
final player = players[index]; final player = players[index];
final isPlaying = player.state == 'playing'; final isPlaying = player.state == 'playing';
@@ -327,7 +459,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
albumArtUrl: albumArtUrl, albumArtUrl: albumArtUrl,
isSelected: false, isSelected: false,
isPlaying: isPlaying, isPlaying: isPlaying,
isGrouped: player.isGrouped, isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
backgroundColor: cardBgColor, backgroundColor: cardBgColor,
textColor: cardTextColor, textColor: cardTextColor,
onTap: () { onTap: () {
@@ -359,6 +491,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
onPower: () { onPower: () {
maProvider.togglePower(player.playerId); 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);
},
),
);
}
+32 -21
View File
@@ -19,11 +19,12 @@ class PlayerSelector extends StatelessWidget {
final availablePlayers = maProvider.availablePlayers; final availablePlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing (excluding selected player) // Count players that are currently playing MA content (excluding selected player)
// User already sees if selected player is playing, badge shows "other" activity // 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 selectedPlayerId = selectedPlayer?.playerId;
final playingCount = availablePlayers final playingCount = availablePlayers
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId) .where((p) => p.state == 'playing' && p.playerId != selectedPlayerId && !p.isExternalSource)
.length; .length;
return Padding( return Padding(
@@ -202,25 +203,31 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
// but still have track info - treat as "has content" for display // but still have track info - treat as "has content" for display
final isIdle = player.state == 'idle'; 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 // Get track info - use current track for selected player, cache for others
final playerTrack = isSelected // Skip track info for external sources
? currentTrack final playerTrack = isExternalSource
: maProvider.getCachedTrackForPlayer(player.playerId); ? null
: (isSelected
? currentTrack
: maProvider.getCachedTrackForPlayer(player.playerId));
// Player has content if playing, paused, or idle with cached track // Player has content if playing, paused, or idle with cached track (but not external source)
final hasContent = isPlaying || isPaused || (isIdle && playerTrack != null); 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; String? albumArtUrl;
if (playerTrack != null && isOn && hasContent) { if (!isExternalSource && playerTrack != null && isOn && hasContent) {
albumArtUrl = maProvider.getImageUrl( albumArtUrl = maProvider.getImageUrl(
playerTrack.album ?? playerTrack, playerTrack.album ?? playerTrack,
size: 128, size: 128,
); );
} }
// Check if this player is part of a sync group // Check if this player is manually synced (not a pre-configured group)
final isGrouped = player.isGrouped; final isGrouped = maProvider.isPlayerManuallySynced(player.playerId);
// Pastel yellow for grouped players // Pastel yellow for grouped players
const groupBorderColor = Color(0xFFFFF59D); const groupBorderColor = Color(0xFFFFF59D);
@@ -321,11 +328,13 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? colorScheme.onSurface.withOpacity(0.24) ? colorScheme.onSurface.withOpacity(0.24)
: !isOn : !isOn
? colorScheme.onSurface.withOpacity(0.3) ? colorScheme.onSurface.withOpacity(0.3)
: isPlaying : isExternalSource
? Colors.green ? Colors.cyan // external source (optical, Spotify, etc.)
: hasContent : isPlaying
? Colors.orange // paused or idle with content ? Colors.green
: colorScheme.onSurfaceVariant.withOpacity(0.5), : hasContent
? Colors.orange // paused or idle with content
: colorScheme.onSurfaceVariant.withOpacity(0.5),
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -335,9 +344,11 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
? S.of(context)!.playerStateUnavailable ? S.of(context)!.playerStateUnavailable
: !isOn : !isOn
? S.of(context)!.playerStateOff ? S.of(context)!.playerStateOff
: hasContent && playerTrack != null : isExternalSource
? playerTrack.name ? S.of(context)!.playerStateExternalSource
: S.of(context)!.playerStateIdle, : hasContent && playerTrack != null
? playerTrack.name
: S.of(context)!.playerStateIdle,
style: TextStyle( style: TextStyle(
color: player.available color: player.available
? colorScheme.onSurfaceVariant ? colorScheme.onSurfaceVariant
@@ -400,8 +411,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
], ],
), ),
), ),
// Play/Pause button // Play/Pause button (hidden for external sources - controls don't work)
if (player.available && isOn) if (player.available && isOn && !isExternalSource)
IconButton( IconButton(
icon: Icon( icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
+2 -1
View File
@@ -1,7 +1,7 @@
name: ensemble name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 2.6.0-beta+28 version: 2.7.4-beta+36
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -28,6 +28,7 @@ dependencies:
# Storage # Storage
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
flutter_secure_storage: ^9.2.2
drift: ^2.22.0 drift: ^2.22.0
drift_flutter: 0.2.0 drift_flutter: 0.2.0
path_provider: ^2.1.5 path_provider: ^2.1.5