Compare commits

..
63 Commits
Author SHA1 Message Date
Chris f7fe6dfbbe Add MA_BETA14_IMPACT.md 2026-08-25 21:41:35 +01:00
Chris cb07e55d69 Merge: per-ABI versionCode override for F-Droid 2026-05-05 06:49:22 +01:00
Chris cf0336e99d Bump version to 3.0.5+57 2026-05-05 06:45:09 +01:00
ChrisandClaude Opus 4.7 73fd1dbdcd Add per-ABI versionCode override for F-Droid split builds
Required by F-Droid for Flutter apps using --split-per-abi: each
per-ABI APK gets a unique versionCode = base * 10 + abiCode
(armeabi-v7a=1, arm64-v8a=2, x86_64=3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 06:08:46 +01:00
Chris 2eb22cf524 Add new sponsor to README
Added a sponsor section with a new sponsor link.
2026-02-24 15:28:20 +00:00
ChrisandClaude Opus 4.6 3fe7a5800a Reduce log noise and add AA diagnostic logging
Downgrade high-frequency polling logs to debug level (PositionTracker,
Sendspin state, queue metadata, received messages) to prevent the
1000-entry log cap from rotating out useful entries. Add logging when
AA provider is not set or when it becomes available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 06:06:28 +00:00
ChrisandClaude Opus 4.6 972690b894 Add QueueHandler mixin for Android Auto queue management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:28:03 +00:00
ChrisandClaude Opus 4.6 0e5679262c Add Android Auto unknown sources setup steps to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:19:16 +00:00
ChrisandClaude Opus 4.6 67bae29f9d Add Android Auto to features and setup in README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:17:36 +00:00
ChrisandClaude Opus 4.6 4f7fbe92d9 Remove duplicate contributors section from README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 07:14:14 +00:00
ChrisandClaude Opus 4.6 0c2853b3df Add antoinevandenhurk as contributor for Android Auto work
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 06:55:33 +00:00
ChrisandClaude Opus 4.6 0d0ca75a6a Bump version to 3.0.4+56
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 06:55:33 +00:00
Chrisandantoinevandenhurk 661888ec37 Restructure Android Auto browse tree with Home, Music, Audiobooks, Podcasts, Radio
Co-authored-by: antoinevandenhurk <35188384+antoinevandenhurk@users.noreply.github.com>
2026-02-20 06:55:33 +00:00
Chrisandantoinevandenhurk c9b03bf045 Add Android Auto media browsing and playback support
Co-authored-by: antoinevandenhurk <35188384+antoinevandenhurk@users.noreply.github.com>
2026-02-20 06:55:27 +00:00
ChrisandClaude Sonnet 4.6 f287fa2819 Fix Sendspin volume sync: use FlutterVolumeController, remove track cache, fix volume UI
- Replace pcmAudioPlayer.setVolumeGain() with FlutterVolumeController.setVolume() for system volume control
- Add notifyListeners() after local volume updates so volume slider stays in sync
- Sync localPlayerVolume from player_updated events to keep UI accurate
- Fix volume_control.dart to read maLocalVolume from provider instead of _systemVolume
- Change supported_roles to 'player@v1' in Sendspin handshake
- Remove track background sync from SyncService (tracks load on-demand)
- Simplify getAlbumTracks/getPlaylistTracks error handling (rethrow instead of swallow)
- Pass album to getAlbumTracks in AlbumDetailsScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 19:50:05 +00:00
ChrisandClaude Sonnet 4.6 e50ca040d8 Fix MA→Ensemble volume and hardware button sync
MA→Ensemble: supported_roles must be ['player'] not ['player@v1'], and the
key must be 'player@v1_support' not 'player_support'. The server checks for
the literal string "player" in supported_roles to initialise PlayerClient -
without it, self.api.player is always None and volume commands are silently
dropped.

Hardware buttons: FlutterVolumeController listener was updating the UI but
never calling updateLocalPlayerVolume, so hardware volume presses never
reached MA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 08:53:11 +00:00
ChrisandClaude Sonnet 4.6 c7773ead0c Fix Sendspin volume sync: handle server/command message type and report state immediately
MA sends volume via {"type":"server/command","payload":{"player":{"command":"volume","volume":N}}}
but we were handling a dead "volume" case with a different format that MA never sends.

Also: updateLocalPlayerVolume now calls reportState immediately instead of waiting for
the 5-second periodic timer, so volume changes in Ensemble reach MA without delay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 08:21:16 +00:00
ChrisandClaude Sonnet 4.6 ba2928a847 Fix Sendspin volume sync: apply PCM gain and report back to MA
Two bugs fixed:

1. MA→Ensemble: Volume commands from MA (HA automations, MA web UI) were
   applied as system media volume via FlutterVolumeController. This is
   unreliable on some devices and incorrect — all other Sendspin clients
   (Snapcast, MassDroid, HA Voice PE) apply volume as software gain on the
   PCM audio stream. Add setVolumeGain() to PcmAudioPlayer and scale Int16
   samples before feeding to flutter_pcm_sound instead.

2. Ensemble→MA: When the user moved the volume slider for the local player,
   _localPlayerVolume was never updated. Every 5 seconds the periodic state
   report would send the stale value to MA, keeping MA's Sendspin player
   volume permanently out of sync. Now VolumeControl calls
   updateLocalPlayerVolume() on all local-player volume changes so the next
   report reflects the actual level.

Fixes: CollotsSpot/Ensemble#70

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 07:20:06 +00:00
ChrisandClaude Opus 4.6 51c94e40a3 Improve author image loading and fix series cover rebuilds
Author images:
- Fetch from all 4 sources in parallel (was sequential) — ~5s vs 30s worst case
- Validate all image URLs with HEAD request before accepting
- Deduplicate concurrent requests for same author via Completer map
- Persist URLs to SharedPreferences for instant cold start
- Increase LRU cache from 100 to 500 entries

Series cover mosaic:
- Cache built cover widget to prevent rebuilds from unrelated setState
- Match hero widget tree structure (RepaintBoundary > AspectRatio > ClipRRect)
- Keep initialCovers stable — don't overwrite from loaded books
- Add ValueKey and memCacheWidth/Height to cover CachedNetworkImages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:32:00 +00:00
ChrisandClaude Opus 4.6 e6b2eb48b8 Add audiobook library cache and fix detail screen loading consistency
- Add _audiobooks in-memory list to provider (bulk loaded like albums)
- Add series audiobook caching for instant revisit
- Fix audiobook cover not loading in detail screen (use _audiobook getter)
- Defer data loading 350ms on audiobook/podcast/playlist detail screens
- Fix artist detail not showing provider albums after background fetch
- Add audiobook case to _removeFromLocalLibrary and _scheduleLibraryRefresh
- Delay color extraction and add memCacheHeight on series hero images

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:59:57 +00:00
ChrisandClaude Opus 4.6 fb867f7410 Fix polling spam and track loss after Chromecast idle-out (#61)
Guard _updatePlayerState() polling against disconnected state to prevent
"Not authenticated" log spam. Cache last-known track per player so it
survives idle-out and can be re-queued when user taps play.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:34:09 +00:00
ChrisandClaude Opus 4.6 e91bdf2353 Add detail track cache, image prefetch, and offline caching improvements
Cache album/playlist tracks in a new DetailTrackCache DB table (schema v7)
so detail screens load instantly on revisit. Prefetch album artwork after
sync via ImagePrefetchService with batched downloads and LRU eviction.
Expand sync_service and cache_service to support the new caching layers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:42:43 +00:00
ChrisandClaude Opus 4.6 547319231b Persist nav bar and mini player during brief disconnects, tune gradient
Keep bottom nav and mini player visible when cached players exist so
the UI doesn't flash during reconnection. Also refine the home screen
bottom fade gradient with intermediate stops to avoid a hard dark line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:27:06 +00:00
ChrisandClaude Opus 4.6 42dc7727a0 Add bottom fade gradient and increase row height on home screen
Adds a gradient overlay above the mini player to smoothly mask content
scrolling behind it, and increases each row height by 2px to push the
4th row title fully behind the mini player.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:38:15 +00:00
ChrisandClaude Opus 4.6 803cd5694c Make APK downloads badge green
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:01:30 +00:00
ChrisandClaude Opus 4.6 64dc18c0fe Change APK downloads badge to latest release only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 20:00:46 +00:00
ChrisandClaude Opus 4.6 47b4e41252 Add APK downloads badge to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:58:55 +00:00
Chris 6920ea0bbd Update GitHub release badge in README.md 2026-02-11 12:57:22 +00:00
Chris d1d29486d6 Update GitHub release badge color in README 2026-02-11 07:52:04 +00:00
ChrisandClaude Opus 4.6 e84515664a Dismiss keyboard on search interaction and fix search tab re-tap
Re-tapping the search nav tab now scrolls to top and focuses the search
field (#67). Keyboard dismisses on scroll (onDrag), tap, or long-press.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 05:49:29 +00:00
ChrisandClaude Opus 4.6 57cb13b26d Fix menu positioning behind mini player and simplify row height calc
Context menu and options menu now account for mini player/nav bar insets
so they don't get clipped behind the player overlay. Detail screens use
different bottom spacing since the nav bar is hidden. Home screen row
height calculation simplified by removing the caching/ratchet mechanism.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 05:34:39 +00:00
ChrisandClaude Opus 4.6 844bce2eea Bump version to 3.0.3+55 for v3.0.3-beta release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:08:19 +00:00
ChrisandClaude Opus 4.6 49ccbc618a Reduce player reveal jitter and remove unused Maven mirrors
Simplify blur backdrop to static filter, defer color extraction until
after reveal animation, and use Selector to prevent unnecessary rebuilds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:52:57 +00:00
ChrisandClaude Opus 4.6 90cb699566 Fix jarring home screen refresh and row size jump on app resume
Replace UniqueKey-based row refresh with ValueNotifier signal so rows
reload data in-place without being destroyed/recreated. Only invalidate
home cache when connecting to a different server (not on routine
reconnects after backgrounding). Cache row height to prevent size jumps
caused by the bottom nav bar toggling on connect/disconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 07:29:55 +00:00
ChrisandClaude Opus 4.6 92777197ac Normalize discovery rows and eliminate unnecessary home screen refreshes
Discovery rows now behave like regular rows - always kept in _homeRowOrder
with visibility controlled by _isRowEnabled(), eliminating flash for non-Tidal
users. Tab-switch from settings only reloads local preferences (no API calls).
Settings scroll position preserved via ScrollController.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 06:29:28 +00:00
ChrisandClaude Opus 4.6 688b6354a8 Fix hardware volume in background, improve player reveal UX
- Stop volume observer and key interception on app pause so hardware
  buttons only control the foreground app (fixes volume affecting MA
  player while in YouTube etc.)
- Restore volume interception on app resume
- Animate blur backdrop fade in (250ms) and out (120ms) for player
  reveal instead of instant toggle
- Start blur fade-out immediately on dismiss, in parallel with card
  slide animation
- Extend mini player gesture hit area 20px above for easier swipe-down,
  especially on detail screens where player sits closer to screen edge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:15:44 +00:00
ChrisandClaude Opus 4.6 d29ce362e7 Add bottom fade gradient to detail screens and reduce unnecessary refreshes
- Add gradient fade overlay to all 8 detail screens so content fades
  smoothly behind the mini player instead of cutting off abruptly
- Fix player reveal gap on detail screens by accounting for hidden nav bar
  in miniPlayerBottom calculation
- Stabilize home row height by using constant margin calculation (prevents
  subtle row size jitter when app resumes and row count fluctuates)
- Remove settings reload on app resume; only reload when switching from
  Settings tab back to Home tab
- Skip discovery folders API call if already loaded (just re-sync row order)
- Only invalidate home cache after real disconnects, not routine reconnects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:19:16 +00:00
Chrisandrelic664 0a385af168 Credit relic664 for discovery/Tidal mixes concept
The discovery rows feature (commits efb84a4..0263c75) was inspired by
and based on work from PR #64 by relic664.

Co-Authored-By: relic664 <relic664@pm.me>
2026-02-09 11:19:40 +00:00
ChrisandClaude Opus 4.6 c448c4ab19 Fix row height, library assertions, album refresh, and settings sync
- Fix home screen row height formula for extendBody:true (use 106px
  mini player space instead of 162px which double-counts nav bar)
- Fix 4+ row scroll padding to use mini player space
- Wrap Artists/Albums tabs with Consumer to fix context.select assertion
- Force home cache refresh after auth to fix race condition where
  AlbumRow fires before credentials are ready
- Add .catchError() to individual futures in getRecentAlbums batch
- Fix settings toggle to add enabled rows to homeRowOrder
- Animate mini player position on detail screen nav bar transitions
- Prevent discovery loading placeholder for users without Tidal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:15:51 +00:00
Chris ea7144d81d Merge PR #69: Sync group volume management, hardware volume buttons, and UI bug fixes
Sync groups, volume management and UI bug fixes.
2026-02-09 11:13:39 +00:00
ChrisandClaude Opus 4.6 48afca040e Add sponsors section to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:08:40 +00:00
ChrisandClaude Opus 4.6 c85f19d1d3 Adjust spacing in album and playlist detail screens
Reduce gaps in album details and fix playlist track list padding for better alignment of track numbers, artwork, and durations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:32:40 +00:00
pcsokonay 1f3a3b3c1d Sync group volume management and UI bug fixes.
This PR represents a testing and debugging cycle following v3.0.2. Initial work on sync group volume management uncovered multiple UI bugs that required fixes. The final result addresses:

1. **Fix: Group Player Volume Management** - Two distinct volume control issues for Music Assistant sync groups
2. **Feature: Hardware Volume Button Integration** - Phone volume buttons now control Music Assistant player volume from lockscreen / shade widget / app minimised
3. **UI Bug Fixes** - Five UI issues discovered during sync group testing, which had to be fixed to get the sync groups to work without crashes
4. **Fix: Cold Start Homepage Race Condition** - Intermittent empty homepage on app cold start - surfaced once sync group volume calculations were introduced.
5. **Code Quality: Cleanup and Optimization** - Removes dead code and eliminates duplicate logic - mine, not yours, I tried to not to touch your code as much as possible

All changes have been tested and verified with `dart analyze` showing no new issues.

---

**Issue:** Volume control for Music Assistant sync groups had two distinct problems depending on how the group was created:

1. **Server-side groups**: Volume slider would snap back to 0 immediately after release
2. **In-app created groups**: Adjusting the leader's volume only changed that one player, not all members proportionally

**Root Cause:** The Music Assistant API returns `volume_level: null` for certain group players (pre-configured speaker groups with `provider == 'player_group'` and some server-side sync groups). The app had no logic to handle this case, so the null volume was interpreted as 0.

**Solution:** Implemented `GroupVolumeManager` service with dual-tier volume protection:

- **Tier 1 (GroupVolumeManager):** Computes effective volume from group members (detects groups, resolves nested, averages volumes, caches pending for 30s, confirms with ±3 tolerance).
- **Tier 2 (Provider-level):** Optimistic update protection (caches for 8s/30s, prevents stale overwrites).

**Root Cause:** For in-app groups, leader has non-null volume, so `setVolume()` only affects leader, not members.

**Expected behavior:** Leader adjustment changes all members proportionally; individual adjustments affect only that member.

**Solution:** In `setVolume()`, for group leaders with non-null volume:
1. Calculate proportional: `memberNewVol = (memberCurrentVol * newVol / leaderCurrentVol).round()` (handle zero case with absolute).
2. Cache pending, update optimistically, fire parallel API calls.

**Files Changed:**
- `lib/services/group_volume_manager.dart` (NEW - 162 lines)
- `lib/providers/music_assistant_provider.dart` (added manager, helper, updated events/state/volume methods)

---

**Feature:** Phone hardware volume buttons (including lockscreen) control MA player volume, with system HUD mirroring.

**Implementation:**
- **Android Native:** `MainActivity.kt` (~120 lines) - VolumeContentObserver, MethodChannel commands, lifecycle.
- **Flutter Service:** `hardware_volume_service.dart` (~65 lines) - streams, observer methods, sync.
- **Integration:** `main.dart` (~35 lines) - adjust/set/update methods, subscription.

**Why This Matters:** Seamless remote control from lockscreen/tray/buttons.

---

During sync group testing, fixed these UI bugs:

**File:** `lib/main.dart`

**Issue:** Console warnings during init.

**Root Cause:** Init code outside `runZonedGuarded()`.

**Fix:** Move init inside `runZonedGuarded()`.

---

**Files:** `lib/screens/home_screen.dart`, `lib/widgets/global_player_overlay.dart`

**Issue:** Crash on nav interaction.

**Root Cause:** Nav in Stack without Overlay.

**Fix:** Move nav to Scaffold.bottomNavigationBar, add extendBody: true.

---

**Files:** `lib/widgets/artist_card.dart`, `lib/widgets/album_card.dart`

**Issue:** Warnings causing corruption.

**Root Cause:** Service calls in initState trigger setState during build.

**Fix:** Defer with addPostFrameCallback, add mounted guard.

---

**File:** `lib/screens/new_library_screen.dart`

**Issue:** Crash on library tabs.

**Root Cause:** context.select in sliver context.

**Fix:** Use Consumer wrappers for tabs.

---

**File:** `lib/widgets/player/queue_panel.dart`

**Issue:** Crash on long-press buttons.

**Root Cause:** Tooltips require Overlay, but in Stack.

**Fix:** Remove tooltip properties.

---

**File:** `lib/screens/settings_screen.dart`

**Issue:** Back button crashes to black screen.

**Root Cause:** Navigator.pop pops home screen.

**Fix:** Use showPlayer + setSelectedIndex(0).

---

**Issue:** Intermittent empty homepage on cold start.

**Root Cause:** isConnected true before auth complete, leading to failed fetches.

**Solution:** Delay broadcasting 'connected' state until auth if required (`music_assistant_provider.dart:1058-1101`).

---

The `GroupVolumeManager` is a stateful service that handles **server-side groups with null volume**:

1. **Detects group players:**
   ```dart
   bool isGroupPlayer(Player player) {
     // Pre-configured MA speaker groups
     if (player.provider == 'player_group') return true;
     // Dynamic sync groups: have multiple members but MA returns null volume
     if (player.groupMembers != null &&
         player.groupMembers!.length > 1 &&
         player.volumeLevel == null) {
       return true;
     }
     return false;
   }
   ```

2. **Computes effective volume:**
   - Recursively resolves nested groups (max depth 3)
   - Averages volumes across all leaf members
   - Skips self-references in `groupMembers`
   - Returns `null` if no members have valid volumes

3. **Caches pending volumes:**
   - When user sets volume: stored with timestamp
   - Returns cached value if within 30s timeout
   - Clears cache when member average converges (±3 tolerance)
   - Falls back to computed average after timeout

4. **Integrates with provider:**
   - Called from `_applyVolumeProtection()` in provider
   - Receives fresh `allPlayers` list for accurate member volumes
   - Injects effective volume into `_selectedPlayer` via `copyWith()`

For **in-app created groups with non-null leader volume**, proportional propagation works as follows:

1. **Detection:** `setVolume()` checks if `player.isGroupLeader && !isGroupPlayer(player)`
   - Leader has `groupMembers` with 2+ entries
   - Leader has its own non-null `volumeLevel`
   - NOT detected as a group player (GroupVolumeManager handles those)

2. **Calculation:** For each member in `groupMembers` (excluding leader itself):
   ```dart
   if (leaderCurrentVol > 0) {
     memberNewVol = (memberVol * newVol / leaderCurrentVol).round().clamp(0, 100)
   } else {
     memberNewVol = newVol  // Can't ratio from zero
   }
   ```

3. **Execution:**
   - Cache `_pendingVolumes[memberId]` and `_pendingVolumeTimestamps[memberId]`
   - Update `_availablePlayers[idx]` optimistically
   - Fire `_api?.setVolume(memberId, memberNewVol)` without awaiting
   - Continue to send leader's own `volume_set` command

4. **Result:** All member volumes scale proportionally with the leader, preserving relative balance.

```
WebSocket connects → MAConnectionState.connected
                    ↓
         Auth required?
        ↙              ↘
      YES              NO
       ↓                ↓
  Don't notify    Set state + notify
  Try auth              ↓
       ↓          Initialize
   Success?
  ↙        ↘
YES        NO
 ↓          ↓
auth     error state
event     + notify
 ↓
Set state
+ notify
 ↓
Initialize
```

**Key:** Listeners only see `isConnected = true` after auth completes (or immediately if no auth required).

android/.../MainActivity.kt                      |  120 + (NEW - ContentObserver)
lib/main.dart                                    |  207 ±
lib/providers/music_assistant_provider.dart      |  165 ±
lib/screens/home_screen.dart                     |  172 ± (BottomNav moved here)
lib/screens/new_library_screen.dart              |   70 ± (Consumer wrappers)
lib/screens/settings_screen.dart                 |    9 ± (back button fix)
lib/services/group_volume_manager.dart           |  162 + (NEW)
lib/services/hardware_volume_service.dart        |   65 + (NEW)
lib/widgets/album_card.dart                      |   23 ± (postFrameCallback)
lib/widgets/artist_card.dart                     |   23 ± (postFrameCallback)
lib/widgets/global_player_overlay.dart           |  171 - (BottomNav moved out)
lib/widgets/player/queue_panel.dart              |    2 - (tooltip removed)

**Total:** ~650 added, ~300 removed/refactored.
2026-02-08 21:16:30 +11:00
ChrisandClaude Opus 4.6 501fe0967a Adjust detail screen spacing: add 5px back below cover art
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:09:07 +00:00
ChrisandClaude Opus 4.6 f17bfdfa55 Reduce spacing below cover art on artist and author detail screens
Tightens the gap between the cover image and the content below so album/audiobook
titles aren't obscured by the mini player. Also centers the context menu button
vertically with the author name and book count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 08:59:34 +00:00
ChrisandClaude Opus 4.6 be1d7c3b50 fix: force sync when in-memory cache is empty regardless of timestamp
SyncService was skipping sync when the database had a recent timestamp
but in-memory lists were empty (e.g., after app restart). This caused
library screens to show no data until a manual refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 21:52:48 +00:00
ChrisandClaude Opus 4.6 1d7cbdeeed Fix: Add Overlay wrapper and checks to prevent red screen error
- Wrap GlobalPlayerOverlay with Overlay in main.dart to provide Overlay ancestor
- Add Overlay.maybeOf check in HiResBadge before using Tooltip
- Add Overlay.maybeOf check in search_screen library-only toggle before using Tooltip

This fixes "No Overlay widget found" error that caused red screen in debug builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:45:03 +00:00
ChrisandClaude Opus 4.6 d62e1169ea Fix setState and context.select errors causing red screen
- Replace context.select with context.watch in PageView.builder tabs
  (context.select cannot be used inside PageView.builder/SliverList)
- Fix LibraryStatusMixin setState during build error using Future.microtask
- Both fixes resolve the red screen issue after login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:34:05 +00:00
ChrisandClaude Opus 4.6 3970839133 Fix: Show all available home screen rows in settings
Commit 0263c75 reduced defaultHomeRowOrder to 3 rows but also
introduced a bug where _filteredRowOrder only showed rows currently
in _homeRowOrder. This made most row options invisible in settings.

Added _allAvailableRows constant (13 rows) and modified _filteredRowOrder
to show all available static rows regardless of their current enabled state.

Users can now see and toggle all 13 home screen rows:
- 3 enabled by default (recent albums, discover artists, discover albums)
- 10 disabled by default (all other rows)

Fixes regression from commit 0263c75 (Feb 5, 2025)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:18:37 +00:00
ChrisandClaude Opus 4.6 8181e75a08 Fix detail screen layout: reduce cover size and spacing
- Reduce artist and audiobook author cover size from 70%/280px to 50%/200px
- Reduce expanded height padding for better space utilization
- Reduce top padding across all detail screens from 24px to 8px
- Move audiobook author three-dot menu from SliverAppBar to inline with author info

This fixes issue #60 where album art/titles were cut off by bottom menu bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 09:59:15 +00:00
ChrisandClaude Opus 4.6 9600f4bcfe Fix search race conditions with request tracking (#62)
Implement "latest request wins" pattern to prevent stale search results from overwriting current results when multiple searches are in flight. This was causing results to suddenly switch to only show "All", "Radio", "Podcasts" categories when typing quickly.

- Add _currentSearchId to track the active search request
- Add mounted check to Timer callback in _onSearchChanged
- Add search ID validation after each async operation (radio/podcast search)
- Only update state if the search is still current when results arrive
- Reset _hasPendingSearch flag when search completes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 11:02:54 +00:00
ChrisandClaude Opus 4.6 a2b8b1eeef Fix home screen refresh logic and prevent empty cache persistence
- Add home refresh counter to signal UI refresh after authentication
- Smart cache invalidation: only refresh home cache if it was empty
- Prevent caching empty results to avoid persisting auth errors
- Add ensureHomeRowsLoaded() to populate cache on first access
- Prevent duplicate home row loads with shared future
- Fix SendspinService emitting after disposal
- Reset auth state on WebSocket disconnect
- Change volume precision mode default to false

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 07:51:14 +00:00
ChrisandClaude Opus 4.6 0263c75804 Refactor discovery rows: remove master toggle, simplify defaults, and fix bugs
- Remove Discovery Mixes master toggle entirely
- Discovery rows now auto-appear based on provider availability (e.g., Tidal)
- Discovery rows are off by default on first launch
- All rows (discovery and static) now behave consistently:
  * Enabled rows are added to end of list, not inserted at fixed positions
  * Users can drag rows to any position and they stay there
- Simplified default row order to only include: recently played,
  discover artists, and discover albums
- Fixed critical bug: discovery rows now correctly default to false
- Fixed SharedPreferences variable shadowing bug in setDiscoveryRowPreferences
- Fixed grey section in settings draggable list by filtering discovery-mixes
- Added localization strings for discovery rows UI
- Removed debug print statements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 22:57:02 +00:00
ChrisandClaude Opus 4.6 469e77c572 Add loading placeholder for discovery folders
Show a loading indicator when discovery folders are being fetched,
ensuring pull-to-refresh works even on empty screens while discovery
is enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:00:58 +00:00
ChrisandClaude Opus 4.5 577b03bbea Move discovery mixes to draggable list and fix empty screen pull-to-refresh
- Move discovery mixes toggle from standalone to draggable row list in settings
- Add empty state widget when no home screen rows are enabled
- Fix pull-to-refresh not working when screen is empty by using SizedBox.expand
- Add localization strings for empty state hint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 18:23:57 +00:00
ChrisandClaude Opus 4.5 7bbba90fff Fix discovery row height - match AlbumRow textAreaHeight of 44px
DiscoveryRow was using 52px for textAreaHeight vs AlbumRow's 44px,
causing smaller artwork and different row appearance.
Changed to 44px to match other rows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:48:48 +00:00
ChrisandClaude Opus 4.5 6f66a1ebff Fix discovery rows crash - add missing import and handle firstWhere error
- Add import for RecommendationFolder model
- Wrap firstWhere in try-catch to handle missing folders gracefully
- Fixes the grey screen issue when discovery toggle is enabled

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:39:07 +00:00
Chris 0ceab80a14 Fix compilation errors in discovery row implementation
- Remove references to non-existent RadioStation, Podcast, PodcastEpisode classes
- Fix MediaType.podcastEpisode enum value (camelCase, not snake_case)
- Use MediaItem base class for radio, podcast, podcast_episode types
2026-02-05 15:31:45 +00:00
ChrisandClaude Opus 4.5 efb84a4e50 Implement dynamic discovery rows architecture
Instead of rendering multiple discovery shelves in a single MixesRow,
each discovery folder now becomes its own row in the home screen,
following the standard 237px height pattern.

Changes:
- Add RecommendationFolder model with polymorphic JSON parsing
- Add DiscoveryRow widget for displaying a single discovery folder
- Add discovery folders to MusicAssistantProvider (getDiscoveryFoldersWithCache)
- Add discovery folders to CacheService and MusicAssistantAPI
- Add "Discovery Mixes" toggle to settings (default off)
- Update home screen to dynamically register discovery rows

This fixes the architectural issue where MixesRow broke the home
screen's assumption that each row entry renders exactly one row
with a fixed height.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 15:04:32 +00:00
Chris 731f45bea9 Style badges with for-the-badge 2026-02-05 09:54:38 +00:00
Chris 32e61f109e Add release version and license badges to README 2026-02-05 09:52:46 +00:00
Chris cd9e82b1fd Add FUNDING.yml for GitHub Sponsors button 2026-02-05 09:20:30 +00:00
ChrisandClaude Opus 4.5 04322cf768 Add donation support feature
Add GitHub Sponsors and Ko-fi donation buttons in settings screen,
along with an About section showing app version and links to
GitHub repository and issue tracker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 09:19:12 +00:00
92 changed files with 7102 additions and 2581 deletions
+3
View File
@@ -0,0 +1,3 @@
# These are the funding models supported by Ensemble
github: CollotsSpot
custom: https://ko-fi.com/collotsspot
+388
View File
@@ -0,0 +1,388 @@
# Android Auto Implementation Plan for Ensemble
## 1. Overview
This document describes adding Android Auto support to Ensemble. When a user connects their Android phone to a car head unit, Android Auto will browse and play music through Ensemble without touching the phone.
### What we're building
- A browse hierarchy (Favorites, Playlists, Albums, Radio) accessible from the car screen
- Playback from Android Auto routes through the builtin (local) player, which streams through MA as normal
- Voice search support
### Why builtin player only
Android Auto requires local audio output. The builtin player plays audio through `just_audio` on the device, which the car receives over Bluetooth. Remote MA players are irrelevant in a car context.
---
## 2. Architecture
### Provider reference
`MassivAudioHandler` has no reference to `MusicAssistantProvider` today. The handler is created before the provider (in `main()` before `runApp`). Solution: add a `setProvider()` method and call it from `_MusicAssistantAppState.initState()`.
```
main.dart
audioHandler = await AudioService.init(...) // global, created first
runApp(MusicAssistantApp())
_MusicAssistantAppState.initState()
_musicProvider = MusicAssistantProvider()
audioHandler.setProvider(_musicProvider) // NEW
```
### Browse → play call flow
```
Android Auto MassivAudioHandler MusicAssistantProvider
| | |
getChildren('root') -----> | returns category items |
getChildren('cat|playlists')-> | SyncService.cachedPlaylists |
getChildren('playlist|lib|5')-> | provider.getPlaylistTracksWithCache()
| populates _trackQueueCache |
playFromMediaId('track|lib|7|plist|lib|5') -> |
| lookup _trackQueueCache |
| provider.playTracks(builtinId, tracks, startIndex: i)
```
---
## 3. Media ID Scheme
Separator: `|` (pipe). Provider names use underscores/colons, never pipes — safe.
| ID | Meaning |
|----|---------|
| `root` | Root (browsable) |
| `recent` | Currently playing track (playable) |
| `cat\|favorites` | Favorites category (browsable) |
| `cat\|playlists` | Playlists category (browsable) |
| `cat\|albums` | Albums category (browsable) |
| `cat\|radio` | Radio stations category (browsable) |
| `playlist\|{provider}\|{itemId}` | A playlist (browsable) |
| `album\|{provider}\|{itemId}` | An album (browsable) |
| `track\|{tProvider}\|{tItemId}\|{ctxType}\|{ctxProvider}\|{ctxId}` | A track in context (playable) |
| `radio\|{provider}\|{itemId}` | A radio station (playable) |
The `ctxType|ctxProvider|ctxId` suffix in track IDs encodes the parent container. Context types: `plist`, `album`, `favs`, `search`.
**Track queue cache key** = `{ctxType}|{ctxProvider}|{ctxId}` (e.g. `plist|spotify|abc`). Stored when building children, looked up in `playFromMediaId`.
---
## 4. Browse Hierarchy
```
root
├── recent (playable, shown if something is/was playing)
├── cat|favorites (browsable → favorite tracks, all playable)
├── cat|playlists (browsable → list of playlists, each browsable)
│ └── playlist|{provider}|{id} → tracks (playable)
├── cat|albums (browsable → list of albums, each browsable)
│ └── album|{provider}|{id} → tracks (playable)
└── cat|radio (browsable → radio stations, each playable)
```
Artists are **not included**. Navigating Artist → Albums → Album → Tracks is four taps — too many while driving. Can be added later.
---
## 5. Track Queue Cache
Tapping a track in Android Auto should play the full album/playlist from that track, not just the single track. The handler caches track lists when building children:
```dart
// Key: ctxKey e.g. "plist|spotify|abc" or "album|library|42" or "favs||"
// Value: ordered list of tracks in that container
final Map<String, List<ma.Track>> _trackQueueCache = {};
```
When `getChildren('playlist|spotify|abc')` is called:
1. Fetch tracks via `provider.getPlaylistTracksWithCache('spotify', 'abc')`
2. Store `_trackQueueCache['plist|spotify|abc'] = tracks`
3. Return each track as a playable `MediaItem` with id `track|{t.provider}|{t.itemId}|plist|spotify|abc`
When `playFromMediaId('track|lib|7|plist|spotify|abc')` is called:
1. Parse ctxKey = `plist|spotify|abc`
2. Look up `_trackQueueCache['plist|spotify|abc']`
3. Find track index where `itemId == '7'`
4. Call `provider.playTracks(builtinId, tracks, startIndex: index)`
---
## 6. Key Code for `massiv_audio_handler.dart`
### New imports
```dart
import 'package:rxdart/rxdart.dart';
import '../../providers/music_assistant_provider.dart';
import '../settings_service.dart';
import '../sync_service.dart';
import '../../models/media_item.dart' as ma;
```
Note: `audio_service` exports its own `MediaItem`. Use `ma.MediaItem`, `ma.Track`, `ma.Album`, `ma.Playlist` for app models. The bare `MediaItem` refers to the audio_service type throughout new methods.
### New fields
```dart
MusicAssistantProvider? _provider;
final Map<String, ma.Track> _trackQueueCache = {}; // ctxKey -> tracks (see section 5)
final Map<String, BehaviorSubject<Map<String, dynamic>>> _childrenSubjects = {};
void setProvider(MusicAssistantProvider provider) {
_provider = provider;
}
```
### `getChildren`
```dart
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
final provider = _provider;
if (provider == null) return [];
switch (parentMediaId) {
case AudioService.browsableRootId:
return _buildRoot(provider);
case 'cat|favorites':
return _buildFavorites(provider);
case 'cat|playlists':
return _buildPlaylistList(provider);
case 'cat|albums':
return _buildAlbumList(provider);
case 'cat|radio':
return _buildRadioList(provider);
default:
if (parentMediaId.startsWith('playlist|')) {
final parts = parentMediaId.split('|');
if (parts.length >= 3) return _buildPlaylistTracks(provider, parts[1], parts[2]);
}
if (parentMediaId.startsWith('album|')) {
final parts = parentMediaId.split('|');
if (parts.length >= 3) return _buildAlbumTracks(provider, parts[1], parts[2]);
}
return [];
}
}
```
### `playFromMediaId`
```dart
@override
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) async {
final provider = _provider;
if (provider == null) return;
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
_logger.log('AndroidAuto: playFromMediaId $mediaId');
try {
if (mediaId == 'recent') {
await provider.playPauseSelectedPlayer();
return;
}
if (mediaId.startsWith('radio|')) {
final parts = mediaId.split('|'); // ['radio', provider, itemId]
if (parts.length < 3) return;
final station = provider.radioStations.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => throw Exception('Station not found: $mediaId'),
);
await provider.playRadio(playerId, station);
return;
}
if (mediaId.startsWith('track|')) {
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
final parts = mediaId.split('|');
if (parts.length < 6) return;
final tProvider = parts[1];
final tItemId = parts[2];
final ctxKey = parts.sublist(3).join('|');
final trackList = _trackQueueCache[ctxKey];
if (trackList == null || trackList.isEmpty) {
// Fallback: play single track (minimal Track object)
await provider.playTrack(playerId, ma.Track(itemId: tItemId, provider: tProvider, name: ''));
return;
}
final index = trackList.indexWhere((t) => t.provider == tProvider && t.itemId == tItemId);
await provider.playTracks(playerId, trackList, startIndex: index < 0 ? 0 : index);
return;
}
} catch (e) {
_logger.log('AndroidAuto: playFromMediaId error: $e');
}
}
```
### `search`
```dart
@override
Future<List<MediaItem>> search(String query, [Map<String, dynamic>? extras]) async {
final provider = _provider;
if (provider == null || query.trim().isEmpty) return [];
try {
final results = await provider.searchWithCache(query);
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
const ctxKey = 'search||';
_trackQueueCache[ctxKey] = tracks;
return tracks.map((t) => MediaItem(
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
title: t.name,
artist: t.artistsString,
album: t.album?.name,
duration: t.duration,
artUri: _artUri(provider, t),
playable: true,
)).toList();
} catch (e) {
_logger.log('AndroidAuto: search error: $e');
return [];
}
}
```
### `subscribeToChildren`
```dart
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
_childrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
return _childrenSubjects[parentMediaId]!.stream;
}
```
### `_artUri` helper
```dart
Uri? _artUri(MusicAssistantProvider provider, ma.MediaItem item) {
final url = provider.getImageUrl(item, size: 256);
return url != null ? Uri.tryParse(url) : null;
}
```
---
## 7. Files to Change
### `pubspec.yaml`
Add `rxdart: ^0.28.0` as direct dependency. The `^0.28.0` constraint matches the already-resolved transitive version so no upgrade occurs.
### `android/app/build.gradle`
Change:
```groovy
minSdkVersion flutter.minSdkVersion
```
To:
```groovy
minSdkVersion 23
```
Android Auto requires API 23+. `flutter.minSdkVersion` resolves to 21 — too low.
### `android/app/src/main/AndroidManifest.xml`
Add inside `<application>` tag:
```xml
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
```
The `MediaBrowserService` intent filter on `AudioService` is already present — no changes needed there.
### `android/app/src/main/res/xml/automotive_app_desc.xml` (NEW)
```xml
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
```
### `lib/main.dart`
In `_MusicAssistantAppState.initState()`, after creating `_musicProvider`:
```dart
audioHandler.setProvider(_musicProvider);
```
### `lib/services/audio/massiv_audio_handler.dart`
Add ~200 lines. New imports, fields, `setProvider()`, `getChildren()`, `playFromMediaId()`, `search()`, `subscribeToChildren()`, and private builder helpers. All existing code is untouched.
---
## 8. Testing with DHU (No Car Required)
### Install DHU
In Android Studio: SDK Manager → SDK Tools → Android Auto Desktop Head Unit Emulator.
Or via command line:
```bash
sdkmanager "extras;google;auto"
```
### Enable Android Auto developer mode on phone
1. Settings → Apps → Android Auto → tap version number 10 times
2. In Android Auto developer settings: enable "Unknown sources"
### Run the test session
```bash
# Terminal 1: forward the port
adb forward tcp:5277 tcp:5277
# Terminal 2: start the DHU
$ANDROID_SDK_ROOT/extras/google/auto/desktop-head-unit
```
In Android Auto developer settings on phone: tap "Start head unit server".
### Build and install
```bash
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
```
Launch Ensemble on phone first (so audio service starts). Then tap the media icon in DHU — Ensemble should appear.
### Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| App not visible in DHU | Missing `automotive_app_desc.xml` or wrong meta-data name | Check manifest and XML |
| App visible, browse is empty | `setProvider` not yet called | Check `initState` wiring |
| Build fails | `minSdkVersion` too low | Check `build.gradle` |
| Track plays but wrong song | `_trackQueueCache` miss | Ensure `getChildren` ran before `playFromMediaId` |
---
## 9. Implementation Order
Do these steps in order to test incrementally:
1. Android config files (`build.gradle`, `AndroidManifest.xml`, `automotive_app_desc.xml`) — verify app appears in DHU before writing Dart
2. `pubspec.yaml` + `flutter pub get`
3. `massiv_audio_handler.dart`: add `setProvider`, fields, `subscribeToChildren` — minimum to not crash
4. `main.dart`: wire `setProvider`
5. `massiv_audio_handler.dart`: `getChildren` with hardcoded root categories — verify tree appears in DHU
6. `massiv_audio_handler.dart`: add all `_build*` helpers one by one, test each
7. `massiv_audio_handler.dart`: add `playFromMediaId` — test track playback
8. `massiv_audio_handler.dart`: add `search` — test voice search
---
## 10. Not In Scope
- **Artists browse** — too many taps for driving; albums alone suffice
- **Audiobooks/podcasts** — different UX needs; add later
- **Queue management from Android Auto** — MA queue model is complex
- **Push updates** — `notifyChildrenChanged` stubbed but not wired to sync events
- **Android Automotive OS** — different platform, requires a separate flavor
-391
View File
@@ -1,391 +0,0 @@
# 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
@@ -1,419 +0,0 @@
# 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.*
-119
View File
@@ -1,119 +0,0 @@
# Ensemble Codebase Audit Prompt
Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app.
---
## Prompt
You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files.
**CRITICAL RULES:**
1. DO NOT remove or break any existing functionality
2. All changes must be on the current branch: `audit/code-quality-review`
3. Run `flutter analyze` after each set of changes to verify no regressions
4. Commit changes in logical, reviewable chunks
**AUDIT PHASES:**
### Phase 1: Code Quality Analysis (Read-Only)
First, analyze the codebase without making changes. Create a report covering:
1. **Large File Analysis** - Review these files for refactoring opportunities:
- `lib/screens/new_library_screen.dart` (122KB)
- `lib/screens/search_screen.dart` (129KB)
- `lib/widgets/expandable_player.dart` (107KB)
- `lib/screens/album_details_screen.dart` (48KB)
- `lib/screens/artist_details_screen.dart` (45KB)
2. **Pattern Inconsistencies** - Look for:
- Inconsistent naming conventions (camelCase vs snake_case)
- Mixed widget composition patterns
- Inconsistent error handling approaches
- Duplicate code across screens/widgets
3. **Performance Red Flags** - Identify:
- Unnecessary rebuilds (missing const constructors)
- Heavy computations in build methods
- Missing list item caching (ListView.builder vs ListView)
- Inefficient state management patterns
4. **Scroll Performance Issues** - Specifically audit:
- ScrollController usage and disposal
- Sliver implementations
- AnimatedBuilder/AnimatedWidget usage
- Image loading in scrollable lists
- Any jank-inducing patterns
### Phase 2: Quick Wins (Low Risk, High Impact)
Apply these fixes first:
1. Add missing `const` constructors throughout
2. Fix any `flutter analyze` warnings
3. Remove dead code and unused imports
4. Standardize formatting with `dart format`
### Phase 3: Scroll Animation Optimization
Focus on smoother scrolling:
1. **Image Optimization**
- Ensure CachedNetworkImage is used consistently
- Add appropriate cacheWidth/cacheHeight
- Implement proper placeholder/error widgets
2. **List Performance**
- Convert any ListView to ListView.builder where appropriate
- Add itemExtent where possible for fixed-height items
- Implement AutomaticKeepAliveClientMixin where needed
- Consider RepaintBoundary for complex list items
3. **Animation Smoothing**
- Use `Curves.easeOutCubic` or similar for scroll-linked animations
- Ensure animations run at 60fps (avoid heavy computations during animation)
- Review CustomScrollView/Sliver implementations
### Phase 4: Code Consistency Improvements
1. **Widget Extraction**
- Extract repeated UI patterns into reusable widgets
- Ensure extracted widgets follow existing naming patterns
2. **State Management**
- Verify Provider usage is consistent
- Check for proper disposal of controllers/streams
3. **Error Handling**
- Standardize try/catch patterns
- Ensure consistent loading/error state UI
### Phase 5: Verification
After all changes:
1. Run `flutter analyze` - must pass with 0 issues
2. Run `flutter test` if tests exist
3. Create a summary of all changes made
4. List any recommendations that were NOT implemented (too risky)
---
## Execution Instructions
```bash
# Start the audit
cd /home/home-server/Ensemble
git checkout audit/code-quality-review
# After each phase, commit your changes
git add -A && git commit -m "Audit Phase X: [description]"
# Verify no regressions
flutter analyze
```
---
## Expected Deliverables
1. `AUDIT_REPORT.md` - Findings from Phase 1
2. Multiple commits with clear messages for each phase
3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations
-257
View File
@@ -1,257 +0,0 @@
# Ensemble Codebase Audit Report
**Date**: 2026-01-14
**Scope**: Full codebase audit (104 Dart files, 18 screens)
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
---
## Executive Summary
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
| Severity | Count | Key Themes |
|----------|-------|------------|
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
| **Low** | 15+ | Minor polish items |
**Top 3 Priorities:**
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
---
## Critical Issues
### 1. God Object: MusicAssistantProvider (5,443 lines)
**File**: `lib/providers/music_assistant_provider.dart`
- 54 `notifyListeners()` calls
- 30+ state variables
- 100+ public methods
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
### 2. God Object: NewLibraryScreen (4,209 lines)
**File**: `lib/screens/new_library_screen.dart`
- 10 scroll controllers
- 15+ view mode states
- 15+ sort order states
- Handles: Music, Books, Podcasts, Radio tabs
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
### 3. Sequential Blocking Startup (500-1500ms wasted)
**File**: `lib/main.dart:33-66`
```dart
await DatabaseService.instance.initialize();
await ProfileService.instance.migrateFromOwnerName();
await SettingsService.migrateToSecureStorage();
await SyncService.instance.loadFromCache();
audioHandler = await AudioService.init(...);
```
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
### 4. Design Tokens Exist But Unused
**File**: `lib/theme/design_tokens.dart`
- `Spacing.*` used in only ~6 of 34+ widget files
- `Radii.*` used in only ~5 files
- `IconSizes.*` used in only ~4 files
- **Fix**: Systematic replacement of hardcoded values
### 5. Touch Targets Too Small (32x32)
**File**: `lib/screens/new_library_screen.dart:1805-1867`
- Sort, favorites, view mode buttons are 32x32
- Material Design minimum: 48x48
- **Fix**: Increase to 48x48 or add padding for hit area
### 6. No Batch Database Inserts
**File**: `lib/services/sync_service.dart:212-273`
- 1000 albums = 1000 separate DB transactions
- **Fix**: Wrap in single transaction or use Drift batch insert
---
## High Severity Issues
### Performance
| Issue | File | Impact |
|-------|------|--------|
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
### Architecture
| Issue | File | Impact |
|-------|------|--------|
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
### UI/UX
| Issue | File | Impact |
|-------|------|--------|
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
### Error Handling
| Issue | File | Impact |
|-------|------|--------|
| No Flutter error boundaries | main.dart | Red error screen on build failure |
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
---
## Medium Severity Issues
### Performance
- PaletteGenerator on main isolate (10-30ms blocking)
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
- AppStartup polling loop with fixed 250ms delay
- SystemThemeHelper FutureBuilder recreated on every build
### UI Consistency
- Border radius varies: 8, 12, 20, 22, 25 for same element types
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
- Hardcoded colors for status (Colors.red, Colors.green)
- Inconsistent icon sizes across screens
### State Management
- Over-notification (multiple notifyListeners in single methods)
- Wide Consumer scope (watch instead of select)
- Missing dispose() in some providers
- Inconsistent async state handling (loading/error)
### Navigation
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
- `popToHome()` doesn't clear adaptive colors
- Infinite navigation loop possible (Album → Artist → Album)
- Orphaned Hero animations in podcast_card, radio_station_card
### Data/Caching
- Unbounded `_podcastCoverCache`
- SyncService listener not removed on error
- Redundant API calls between providers
- Fire-and-forget persistence race condition
### Error Handling
- No zone error handler for async exceptions
- `retryCritical()` retries all errors including non-recoverable
- No user notification of offline queue status
- DisconnectedState not used consistently
---
## Quick Wins (High Impact, Low Effort)
| # | Task | File(s) | Effort |
|---|------|---------|--------|
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
| 5 | Add Flutter error boundary | main.dart | 30 min |
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
---
## Refactoring Roadmap
### Phase 1: Quick Wins (1-2 days)
- Fix touch targets
- Fix bottom spacing
- Add navigation debounce
- Add error boundaries
- Cache SharedPreferences singleton
### Phase 2: Performance (3-5 days)
- Parallelize startup operations
- Replace `context.watch()` with `context.select()`
- Batch database inserts
- Add SecureStorage migration skip flag
- Fix over-notification in provider
### Phase 3: Design System (1 week)
- Systematic design token adoption
- Standardize button sizes (44 vs 50)
- Standardize border radius values
- Create StatusColors semantic tokens
- Replace all magic numbers
### Phase 4: Architecture (2-3 weeks)
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
---
## Positive Findings
The codebase already demonstrates many good practices:
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
- **Theme system**: Material You support, adaptive colors from album art
- **Offline support**: Cache-first loading, graceful degradation
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
---
## Files by Priority
| Priority | File | Lines | Key Issues |
|----------|------|-------|------------|
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
| 3 | main.dart | 549 | Startup blocking, error boundaries |
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
| 8 | All detail screens | Various | Hardcoded bottom spacing |
| 9 | All card widgets | Various | Navigation debounce, design tokens |
---
## Audit Methodology
This audit was conducted using 9 parallel sub-agents analyzing:
**UI Continuity (3 agents)**
1. Navigation & Flow - back button, Hero animations, tab preservation
2. Visual Consistency - design tokens, spacing, typography
3. Component Placement - touch targets, bottom spacing, modals
**Performance (3 agents)**
4. Rendering Performance - ListView.builder, const, rebuilds
5. Data & Caching - streams, invalidation, memory bounds
6. Startup & Load Time - blocking operations, initialization order
**Logic/Best Practices (3 agents)**
7. State Management - Provider patterns, notification frequency
8. Error Handling - try-catch, boundaries, resilience
9. Code Quality - file size, duplication, SOLID principles
---
## Conclusion
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
1. **Splitting the two 4,000+ line files** into focused components
2. **Adopting design tokens systematically** for visual consistency
3. **Optimizing startup sequence** for faster app launch
4. **Using granular state management** (select vs watch) for better performance
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
-57
View File
@@ -1,57 +0,0 @@
# Audit Summary
**Branch:** `audit/code-quality-review`
**Date:** January 4, 2026
## Changes Applied
### Phase 1: Analysis Complete
- Created `AUDIT_REPORT.md` with comprehensive findings
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
### Phase 2: Deprecated API Fixes (18 files)
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
**Files modified:**
- lib/screens/library_artists_screen.dart
- lib/screens/library_albums_screen.dart
- lib/screens/library_playlists_screen.dart
- lib/screens/library_tracks_screen.dart
- lib/screens/artist_details_screen.dart
- lib/screens/new_home_screen.dart
- lib/screens/login_screen.dart
- lib/screens/settings_screen.dart
- lib/screens/album_details_screen.dart
- lib/screens/search_screen.dart
- lib/screens/new_library_screen.dart
### Phase 3: Scroll Performance Optimization
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
**lib/screens/new_library_screen.dart:**
- Line 1400-1401: Author list tile image (128x128 cache)
- Line 1487-1488: Author grid card image (256x256 cache)
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
- Line 1749-1750: Audiobook grid cover (512x512 cache)
- Line 1970-1971: Series list tile cover (256x256 cache)
- Line 2986-2987: Album list tile cover (256x256 cache)
## Recommendations NOT Implemented (Low Priority)
These were identified but NOT changed to preserve functionality:
1. **View mode cycling refactor** - 8 similar functions could be consolidated
2. **Color constant extraction** - Duplicated colors in 3 files
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
4. **BorderRadius.circular const** - 40+ instances
## Verification
Changes should be verified by:
1. GitHub Actions build (triggered automatically)
2. Manual UI testing of scroll smoothness
3. Verify no visual regressions in dark/light themes
## Build Trigger
GitHub Actions workflow triggered on branch `audit/code-quality-review`
+167
View File
@@ -0,0 +1,167 @@
# Music Assistant 2.8.0 Beta 14 — Impact on Ensemble
**Date:** 2026-03-03
**Current server:** 2.8.0 beta 12
**Target server:** 2.8.0 beta 14 (released March 2, 2026)
---
## Summary
Beta 14 is a large release (50+ PRs). The headline change — merging multi-protocol players into a single entity — directly overlaps with Ensemble's client-side Cast↔Sendspin consolidation logic. Sendspin also received a major v4.0 overhaul. Most changes should degrade gracefully but player ID persistence will break on upgrade.
---
## Breaking: Player Merging (PR #3150)
MA now merges players that support multiple protocols (e.g. a Chromecast with Cast + Sendspin) into a **single player entity** server-side. Previously these appeared as two separate players.
> *"After this change, all your players will be reset. If you previously had multiple players in Music Assistant per protocol, this will now show up as one single player."*
### What Ensemble does today
Ensemble handles this client-side in `music_assistant_provider.dart:4271-4406`:
- `_castToSendspinIdMap` maps Cast IDs → Sendspin IDs
- When Sendspin is grouped: hides Cast player, shows Sendspin (renamed to base name)
- When ungrouped: hides Sendspin, shows original Cast
- Mappings persisted to database for cold start restoration
### Impact on Ensemble
| Component | Location | Impact | Severity |
|---|---|---|---|
| `_castToSendspinIdMap` + consolidation logic | `provider:4271-4406` | Dead code — server no longer sends separate players | Medium |
| `isManuallySyncedPlayer()` ID translation | `provider:533-595` | Lookups against stale map fail silently (returns `false`) | Low |
| Player selection persistence | Settings + DB | **Stored player IDs invalid after upgrade** — all players reset | **High** |
| `_handlePlayerUpdatedEvent()` ID matching | `provider:2956-3358` | Events arrive with new merged IDs, old translation won't match | Medium |
| Database-persisted Cast↔Sendspin mappings | `DatabaseService` | Stale data, won't match anything | Low |
### Assessment
The consolidation logic should degrade gracefully — it won't find Sendspin suffixes to strip or Cast pairs to merge, so the server's already-merged players pass through `filterPlayers()` as-is. The main risk is **player selection breaking** because saved player IDs no longer exist.
### Action required
1. Verify the existing fallback logic (`provider:4427-4465`) gracefully re-selects when the stored `selectedPlayerId` doesn't exist in the player list
2. Remove or gate the Cast↔Sendspin consolidation code behind a server version check (on beta 14+ it's unnecessary and could cause confusion)
3. Clear stale database mappings on first connection to a beta 14+ server
---
## Sendspin v4.0 (PR #3158)
Major rewrite of the Sendspin server.
### Changes
- New `SendspinPlaybackSession` coordinator architecture
- Per-member DSP pipelines, dynamic group membership, late-join audio backfill
- Max buffer capacity 5 → 30 seconds
- Multiple Sendspin servers with automatic failover
- Per-client codec/sample-rate/bit-depth options
- Album artwork persists during skip/pause
- Reduced latency for live streams
### Impact on Ensemble
| Change | Impact |
|---|---|
| New session architecture | **Likely compatible** — sendspin-js only bumped 2.0.0 → 2.0.1 (minor), suggesting wire protocol didn't fundamentally change |
| Per-client codec options | `stream/start` may contain new fields — safe if Ensemble ignores unknown fields |
| Multiple servers with failover | Ensemble's 3-tier connection strategy may need awareness |
| Buffer increase | No client impact |
### Action required
1. Test Sendspin connection against beta 14 — verify `client/hello`, `stream/start`, state reporting all still work
2. Confirm unknown fields in protocol messages are ignored gracefully
---
## Sendspin-Chromecast Bridge (PR #3255)
Replaces the inline experimental Sendspin mode in `ChromecastPlayer` with a dedicated bridge. Cast devices now use a Cast receiver app with an embedded JS Sendspin client.
### Impact on Ensemble
- **Direct impact: None** — Ensemble is a Sendspin client itself, not going through Cast
- **Indirect:** Changes how grouped Cast players behave, which could affect `GroupVolumeManager`
---
## Group Volume / Grouping Fixes
| PR | Change | Ensemble Impact |
|---|---|---|
| #3192 | Fix grouping for players whose native protocol is also a protocol of other players | Bidirectional grouping translation — may affect group detection logic |
| #3205 | Fix group mute for protocol-synced players (`player.state.group_members` vs `player.group_members`) | `GroupVolumeManager` may see different/correct member lists |
| #3277 | Fix `volume_up`/`volume_down` for group players | Server-side fix, should improve behavior |
| #3118 | Fix sync groups losing members on power off | Affects dynamic group management |
### Action required
1. Test group volume behavior — may "just work better" with server-side fixes
2. Verify `GroupVolumeManager` handles any changes to `group_members` field population
---
## Sendspin Bug Fixes
| PR | Fix | Ensemble Relevance |
|---|---|---|
| #3191 | DSP not applying for Sendspin players | DSP config stored on parent player, looked up by protocol player ID — now fixed |
| #3249 | Update to aiosendspin 4.2.0 | Library update, potential behavior changes |
| #3250 | Metadata sending wrong progress when paused | Progress reporting fix — Ensemble's position tracker may see better data |
| #3258 | Floating point for internal audio data | Audio quality improvement, no client impact |
---
## New APIs — Opportunities
| Feature | PR | Details | Ensemble Opportunity |
|---|---|---|---|
| Playback speed | #3198 | `player_queues/set_playback_speed`, stored in `queue.extra_attributes["playback_speed"]` | Great for audiobooks/podcasts |
| Save queue to playlist | #3149 | `save_as_playlist` command on player queue controller | Easy feature add |
| Mixed-media playlists | #3216 | Playlists can contain tracks + podcasts + audiobooks. DB schema bumped to v30. `get_playlist_tracks()` returns `Sequence[PlaylistPlayableItem]` | Playlist screens may need to handle heterogeneous items |
| Genres v2 | #3164 | New browsing category with icons and SVG support | New library section |
---
## Also Changed in Beta 13 (Skipped)
Relevant changes from beta 13 (between current beta 12 and target beta 14):
- Group volume mute support (#3034)
- Frontend prepared for multiprotocol support (#1409) — groundwork for beta 14 merging
- IPv6 support for zeroconf, stream server, AirPlay (#3086)
- Snapcast player availability alignment (#3104)
- DLNA reconnection fix (#3132)
---
## Recommended Action Plan
### Before upgrading
- [ ] Verify player selection fallback handles missing player IDs gracefully
- [ ] Back up current configuration
### Immediate (after upgrading)
- [ ] Test Sendspin protocol compatibility (connection, streaming, state reporting)
- [ ] Test player list — confirm merged players appear correctly without client-side consolidation
- [ ] Test group volume behavior with new server-side fixes
- [ ] Test player selection persistence across app restarts
### Short-term
- [ ] Remove or version-gate Cast↔Sendspin consolidation code
- [ ] Clear stale database mappings on beta 14+ detection
- [ ] Test mixed-media playlist handling if playlists are used
### Opportunistic
- [ ] Add playback speed control (audiobook/podcast feature)
- [ ] Add save-queue-to-playlist
- [ ] Explore Genres v2 browsing
+31
View File
@@ -1,6 +1,12 @@
<div align="center">
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
[![GitHub release](https://img.shields.io/badge/Release-v3.0.4-blue?style=for-the-badge&logo=github)](https://github.com/CollotsSpot/Ensemble/releases)
[![GitHub Downloads](https://img.shields.io/github/downloads/CollotsSpot/Ensemble/latest/total?style=for-the-badge&logo=android&label=APK%20Downloads&color=green)](https://github.com/CollotsSpot/Ensemble/releases/latest)
[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub-EA4AAA?style=for-the-badge&logo=GitHub%20Sponsors&logoColor=white)](https://github.com/sponsors/CollotsSpot)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF5E5B?style=for-the-badge&logo=Ko-fi&logoColor=white)](https://ko-fi.com/collotsspot)
---
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
@@ -80,6 +86,13 @@ This application was built with AI-assisted development using **Claude Code** an
- **Skip Controls** - Skip forward/backward during playback
- **High-Resolution Artwork** - Fetched via iTunes for best quality
### Android Auto
- **Media Browsing** - Browse your library from your car's display with categories for Home, Music, Audiobooks, Podcasts, and Radio
- **Voice Search** - Find and play tracks using voice commands
- **Full Playback Controls** - Play, pause, and skip from your car's head unit
- **Album Art & Metadata** - Track info and artwork displayed on your car's screen
- **Smart Queueing** - Tap any track in an album or playlist to play from that point with the full queue
### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost
@@ -135,6 +148,17 @@ If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server
- Do **not** use your Home Assistant URL or ingress URL
### Android Auto
Since Ensemble is not installed from the Play Store, you need to enable unknown sources in Android Auto:
1. Open **Android Auto** settings on your phone
2. Tap the **Version** number at the bottom 10 times to enable developer mode
3. Tap the three-dot menu and open **Developer settings**
4. Enable **Unknown sources**
5. Connect your phone to your car via USB or wirelessly
6. Ensemble will appear in Android Auto's app list
### Remote Access
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
@@ -146,6 +170,13 @@ For access outside your home network, you'll need to expose Music Assistant thro
- Android device (Android 5.0+)
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
## Sponsors
A huge thank you to my sponsors! 💖
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
<a href="https://github.com/githubchb"><img src="https://github.com/githubchb.png" width="60px" alt="githubchb" /></a>
## License
MIT License
+11 -1
View File
@@ -30,7 +30,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace "com.collotsspot.ensemble"
compileSdk 35
compileSdk 36
ndkVersion flutter.ndkVersion
compileOptions {
@@ -77,6 +77,16 @@ android {
}
}
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86_64': 3]
android.applicationVariants.configureEach { variant ->
variant.outputs.each { output ->
def abiVersionCode = abiCodes[output.getFilter('ABI')]
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
}
flutter {
source '../..'
}
+10
View File
@@ -1,5 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
@@ -48,6 +54,10 @@
</intent-filter>
</receiver>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -1,5 +1,11 @@
package com.collotsspot.ensemble
import android.content.Context
import android.database.ContentObserver
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
@@ -14,10 +20,22 @@ class MainActivity: AudioServiceActivity() {
private var methodChannel: MethodChannel? = null
private var isListening = false
// Volume observer for lockscreen volume changes
// Watches system STREAM_MUSIC volume and mirrors changes to the MA player
// when a remote/group player is active
private var audioManager: AudioManager? = null
private var volumeObserver: VolumeContentObserver? = null
private var isObservingVolume = false
private var lastKnownVolume: Int = -1
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
private var ignoringVolumeChange = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
methodChannel?.setMethodCallHandler { call, result ->
Log.d(TAG, "Received method call: ${call.method}")
@@ -32,6 +50,24 @@ class MainActivity: AudioServiceActivity() {
Log.d(TAG, "Volume listening DISABLED")
result.success(null)
}
"startVolumeObserver" -> {
// Start observing system volume for lockscreen volume changes.
// Optional: initialVolume (0-100) sets the system volume to match
// the MA player's current volume for a consistent HUD display.
val initialVolume = call.argument<Int>("initialVolume")
startVolumeObserver(initialVolume)
result.success(null)
}
"stopVolumeObserver" -> {
stopVolumeObserver()
result.success(null)
}
"syncSystemVolume" -> {
// Sync system volume to match MA player volume (0-100)
val volume = call.argument<Int>("volume") ?: 0
syncSystemVolume(volume)
result.success(null)
}
else -> {
Log.d(TAG, "Unknown method: ${call.method}")
result.notImplemented()
@@ -40,6 +76,82 @@ class MainActivity: AudioServiceActivity() {
}
}
/// Start observing system STREAM_MUSIC volume changes.
/// When a change is detected, sends an "absoluteVolumeChange" event to Flutter
/// with the new volume mapped to 0-100 scale.
private fun startVolumeObserver(initialVolume: Int?) {
if (isObservingVolume) return
val am = audioManager ?: return
// Optionally set system volume to match the MA player's current volume
if (initialVolume != null) {
syncSystemVolume(initialVolume)
}
lastKnownVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
volumeObserver = VolumeContentObserver(Handler(Looper.getMainLooper()))
contentResolver.registerContentObserver(
Settings.System.CONTENT_URI,
true,
volumeObserver!!
)
isObservingVolume = true
Log.d(TAG, "Volume observer STARTED, initial system volume: $lastKnownVolume")
}
/// Stop observing system volume changes.
private fun stopVolumeObserver() {
if (!isObservingVolume) return
volumeObserver?.let {
contentResolver.unregisterContentObserver(it)
}
volumeObserver = null
isObservingVolume = false
Log.d(TAG, "Volume observer STOPPED")
}
/// Set the system STREAM_MUSIC volume to match an MA player volume (0-100).
/// Maps MA's 0-100 range to the device's 0-maxVolume range.
private fun syncSystemVolume(maVolume: Int) {
val am = audioManager ?: return
val maxSystemVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val systemVolume = (maVolume * maxSystemVolume / 100).coerceIn(0, maxSystemVolume)
ignoringVolumeChange = true
am.setStreamVolume(AudioManager.STREAM_MUSIC, systemVolume, 0)
lastKnownVolume = systemVolume
ignoringVolumeChange = false
Log.d(TAG, "Synced system volume: MA $maVolume% -> system $systemVolume/$maxSystemVolume")
}
/// ContentObserver that watches for system volume changes.
/// When volume changes (e.g., from lockscreen hardware buttons),
/// maps the change to a 0-100 value and sends to Flutter.
inner class VolumeContentObserver(handler: Handler) : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) {
super.onChange(selfChange)
if (ignoringVolumeChange) return
val am = audioManager ?: return
val currentVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
if (currentVolume != lastKnownVolume) {
val maxVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val maVolume = if (maxVolume > 0) (currentVolume * 100 / maxVolume) else 0
Log.d(TAG, "Volume observer: system $lastKnownVolume -> $currentVolume (MA: $maVolume%)")
lastKnownVolume = currentVolume
// Send the absolute volume (0-100) to Flutter
methodChannel?.invokeMethod("absoluteVolumeChange", maVolume)
}
}
}
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
// and may consume events before they reach onKeyDown
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
@@ -84,4 +196,34 @@ class MainActivity: AudioServiceActivity() {
}
}
}
// Pause volume interception when app goes to background so hardware buttons
// only control the system volume (e.g., YouTube, phone ringer) as expected.
private var wasListeningBeforePause = false
private var wasObservingBeforePause = false
override fun onPause() {
wasListeningBeforePause = isListening
wasObservingBeforePause = isObservingVolume
isListening = false
stopVolumeObserver()
Log.d(TAG, "onPause: suspended volume interception (wasListening=$wasListeningBeforePause, wasObserving=$wasObservingBeforePause)")
super.onPause()
}
override fun onResume() {
super.onResume()
if (wasListeningBeforePause) {
isListening = true
}
if (wasObservingBeforePause) {
startVolumeObserver(null)
}
Log.d(TAG, "onResume: restored volume interception (listening=$isListening, observing=$isObservingVolume)")
}
override fun onDestroy() {
stopVolumeObserver()
super.onDestroy()
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,16.5c-2.49,0 -4.5,-2.01 -4.5,-4.5S9.51,7.5 12,7.5s4.5,2.01 4.5,4.5 -2.01,4.5 -4.5,4.5zM12,11c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55C7.79,13 6,14.79 6,17s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M15,6H3v2h12V6zM15,10H3v2h12V10zM3,16h8v-2H3V16zM17,6v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3V8h3V6H17z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M3.24,6.15C2.51,6.43 2,7.17 2,8v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2H8.3l8.26,-3.34 -0.37,-0.92L3.24,6.15zM7,20c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM20,12H12V9h8v3z"/>
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+1 -7
View File
@@ -2,14 +2,11 @@ buildscript {
ext.kotlin_version = '2.1.0'
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.0'
classpath 'com.android.tools.build:gradle:8.9.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
@@ -17,9 +14,6 @@ buildscript {
allprojects {
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral()
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
+1 -1
View File
@@ -22,7 +22,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.7.0" apply false
id "com.android.application" version "8.9.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
+242
View File
@@ -0,0 +1,242 @@
# Discovery Rows Submenu - Implementation Plan
## Option: Hybrid with Discovery Badge
### Overview
Transform the "Discovery Mixes" toggle into a section that:
1. Shows individual discovery row toggles
2. Adds those rows to the main home row list with visual distinction
3. Allows full reordering alongside other rows
---
## UI Design
### Settings Screen - Discovery Section
```
┌─────────────────────────────────────────────┐
│ ⚬ Discovery Mixes ● │
│ Show discovery-based rows on home screen │
│ ──────────────────────────────────────── │
│ Discovery rows from Music Assistant: │
│ │
│ ☑ New Releases ● │
│ ☑ Recently Played ● │
│ ☐ Recommended Albums ○ │
│ ☐ For You ○ │
│ │
│ [Refresh available rows] │
└─────────────────────────────────────────────┘
```
### Main Home Row List - With Badge
Discovery rows appear interleaved with a badge:
```
┌─────────────────────────────────────────────┐
│ ⋮ ☑ Recent Albums ● │
│ ⋮ ☑ Discovery: New Releases ● [MUSIC] │ ← Badge
│ ⋮ ☑ Discover Artists ● │
│ ⋮ ☐ Discovery: For You ○ [MUSIC] │
│ ⋮ ☑ Favorite Albums ● │
└─────────────────────────────────────────────┘
```
Badge style: Small chip/icon indicating this is a dynamic discovery row.
---
## Technical Changes
### 1. Settings Screen (`lib/screens/settings_screen.dart`)
**Add state for discovery folders:**
```dart
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {}; // itemId -> enabled
bool _isDiscoveryFoldersExpanded = false;
```
**New methods:**
```dart
Future<void> _loadDiscoveryFolders() async { }
Future<void> _refreshDiscoveryFolders() async { }
void _toggleDiscoveryRow(String itemId, bool enabled) { }
void _toggleDiscoveryMixes(bool enabled) { }
Widget _buildDiscoverySection() { }
```
**Modify existing:**
- `_loadSettings()` - load discovery row preferences
- Home row list builder - filter out individual discovery rows
- Add discovery section before home row list
### 2. Settings Service (`lib/services/settings_service.dart`)
**New methods:**
```dart
static Future<bool> getShowDiscoveryFolders() async { }
static Future<void> setShowDiscoveryFolders(bool value) async { }
// Per-row discovery preferences
static Future<Map<String, bool>> getDiscoveryRowPreferences() async { }
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async { }
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async { }
```
**Storage key pattern:**
- `discovery_folders_enabled` (bool)
- `discovery_row_{itemId}` (bool per row)
### 3. Provider (`lib/providers/music_assistant_provider.dart`)
**Already has:** `getDiscoveryFoldersWithCache()`
**Add method:**
```dart
Future<void> refreshDiscoveryFolders() async {
// Clear cache and refetch from API
_cacheService.clearDiscoveryFoldersCache();
await getDiscoveryFoldersWithCache();
}
```
### 4. Home Screen (`lib/screens/new_home_screen.dart`)
**Changes to row rendering:**
Instead of a single `_discoveryFolders` list, discovery rows are integrated into `_homeRowOrder`:
```dart
// Before: static list of row IDs
List<String> _homeRowOrder = ['recent-albums', 'discover-artists', ...];
// After: can include discovery row IDs
List<String> _homeRowOrder = [
'recent-albums',
'discovery:abc123', // Discovery row ID
'discover-artists',
'discovery:def456',
...
];
```
**New method:**
```dart
bool _isDiscoveryRowId(String rowId) {
return rowId.startsWith('discovery:');
}
String _getDiscoveryRowItemId(String rowId) {
return rowId.substring('discovery:'.length);
}
```
**Modify row rendering:**
- Check if rowId is a discovery row
- If yes, render DiscoveryRow widget with that folder
- Cache discovery folders for quick lookup
### 5. Localization (`lib/l10n/app_en.arb`)
**Add strings:**
```json
{
"discoveryMixesDescription": "Show personalized recommendations from Music Assistant",
"discoveryRows": "Discovery rows from Music Assistant",
"refreshDiscoveryRows": "Refresh available rows",
"noDiscoveryRows": "No discovery rows available",
"discoveryRowBadge": "Music Assistant"
}
```
### 6. Models (`lib/models/recommendation_folder.dart`)
**Add property:**
```dart
class RecommendationFolder {
...
/// Unique ID for use in row ordering and settings
String get rowId => 'discovery:$itemId';
}
```
---
## Data Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ Settings Screen │
├─────────────────────────────────────────────────────────────────┤
│ 1. Load discovery folders from Provider │
│ 2. Load per-row enabled state from Settings │
│ 3. Render section with toggles │
│ 4. User toggles row → save to Settings │
│ 5. User toggles main → save to Settings + update home row list │
│ 6. User refreshes → Provider.fetch → update UI │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Home Screen │
├─────────────────────────────────────────────────────────────────┤
│ 1. Load home row order from Settings │
│ 2. Identify discovery row IDs (prefix "discovery:") │
│ 3. For discovery rows: look up folder from Provider cache │
│ 4. Render appropriate widget (DiscoveryRow or other) │
│ 5. Handle reordering including discovery rows │
└─────────────────────────────────────────────────────────────────┘
```
---
## Migration Path
### Phase 1: Settings Screen (No breaking changes)
- Add discovery section to settings
- Load and display discovery folders
- Individual row toggles (but don't affect home screen yet)
- Refresh button
### Phase 2: Home Screen Integration
- Modify home row order to support discovery row IDs
- Render discovery rows in main list
- Add badge visual distinction
### Phase 3: Polish
- Translations
- Edge cases (folder disappears from MA)
- Performance (lazy loading)
---
## Edge Cases & Considerations
| Issue | Solution |
|-------|----------|
| Discovery folder no longer returned by MA | Keep user preference, hide row if not found |
| User reorders then MA returns new folders | New folders appended to end of discovery section |
| All discovery rows disabled | Discovery section shows but appears empty/minimized |
| Many discovery folders (10+) | Show scrollable sublist, keep main setting compact |
| Row ID collision | Use `discovery:{itemId}` prefix ensures uniqueness |
---
## Implementation Checklist
- [ ] Settings: Add `_discoveryFolders` state
- [ ] Settings: Add `_discoveryRowEnabled` map
- [ ] Settings: Implement `_loadDiscoveryFolders()`
- [ ] Settings: Implement `_buildDiscoverySection()`
- [ ] Settings: Add refresh button handler
- [ ] SettingsService: Add discovery row preference methods
- [ ] Provider: Add `refreshDiscoveryFolders()`
- [ ] HomeScreen: Support discovery row IDs in order list
- [ ] HomeScreen: Render discovery rows with badge
- [ ] Localization: Add new strings
- [ ] Test: Toggle discovery rows on/off
- [ ] Test: Reorder discovery rows
- [ ] Test: Refresh discovery folders
- [ ] Test: Migrate existing preference to new system
+91 -2
View File
@@ -190,12 +190,37 @@ class SearchHistory extends Table {
DateTimeColumn get searchedAt => dateTime()();
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
/// Cached detail tracks for album/playlist detail screens
/// Persists across app restarts so detail screens load instantly
class DetailTrackCache extends Table {
/// Composite key: "{parentType}_{parentKey}"
TextColumn get cacheKey => text()();
/// Type: 'album' or 'playlist'
TextColumn get parentType => text()();
/// The parent item's cache key (e.g., "provider_itemId")
TextColumn get parentKey => text()();
/// Serialized list of tracks as JSON array
TextColumn get tracksJson => text()();
/// When this was fetched from API
DateTimeColumn get lastFetched => dateTime()();
/// When this was last accessed (for LRU eviction)
DateTimeColumn get lastAccessed => dateTime()();
@override
Set<Column> get primaryKey => {cacheKey};
}
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory, DetailTrackCache])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 6;
int get schemaVersion => 7;
@override
MigrationStrategy get migration {
@@ -234,6 +259,10 @@ class AppDatabase extends _$AppDatabase {
if (from < 6) {
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
}
// Migration from v6 to v7: Add detail track cache table
if (from < 7) {
await m.createTable(detailTrackCache);
}
},
);
}
@@ -696,6 +725,66 @@ class AppDatabase extends _$AppDatabase {
await delete(searchHistory).go();
}
// ============================================
// Detail Track Cache Operations
// ============================================
/// Save detail tracks for an album or playlist
Future<void> saveDetailTracks(String parentType, String parentKey, String tracksJson) async {
final cacheKey = '${parentType}_$parentKey';
final now = DateTime.now();
await into(detailTrackCache).insertOnConflictUpdate(
DetailTrackCacheCompanion.insert(
cacheKey: cacheKey,
parentType: parentType,
parentKey: parentKey,
tracksJson: tracksJson,
lastFetched: now,
lastAccessed: now,
),
);
// Evict old entries if cache grows too large
await _evictDetailTrackCache();
}
/// Get cached detail tracks for an album or playlist
/// Returns null if not cached, updates lastAccessed on hit
Future<DetailTrackCacheData?> getDetailTracks(String parentType, String parentKey) async {
final cacheKey = '${parentType}_$parentKey';
final result = await (select(detailTrackCache)
..where((d) => d.cacheKey.equals(cacheKey)))
.getSingleOrNull();
if (result != null) {
// Update last accessed time
await (update(detailTrackCache)..where((d) => d.cacheKey.equals(cacheKey)))
.write(DetailTrackCacheCompanion(lastAccessed: Value(DateTime.now())));
}
return result;
}
/// Evict oldest detail track cache entries when over limit
Future<void> _evictDetailTrackCache() async {
const maxEntries = 100;
final count = await (selectOnly(detailTrackCache)..addColumns([countAll()]))
.map((row) => row.read(countAll())!)
.getSingle();
if (count <= maxEntries) return;
// Delete oldest by lastAccessed
final oldest = await (select(detailTrackCache)
..orderBy([(d) => OrderingTerm.asc(d.lastAccessed)])
..limit(count - maxEntries))
.get();
for (final entry in oldest) {
await (delete(detailTrackCache)..where((d) => d.cacheKey.equals(entry.cacheKey))).go();
}
}
/// Clear all detail track cache
Future<void> clearDetailTrackCache() async {
await delete(detailTrackCache).go();
}
// ============================================
// Batch Cache Operations
// ============================================
+579 -3
View File
@@ -2916,6 +2916,381 @@ class SearchHistoryCompanion extends UpdateCompanion<SearchHistoryData> {
}
}
class $DetailTrackCacheTable extends DetailTrackCache
with TableInfo<$DetailTrackCacheTable, DetailTrackCacheData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$DetailTrackCacheTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _cacheKeyMeta =
const VerificationMeta('cacheKey');
@override
late final GeneratedColumn<String> cacheKey = GeneratedColumn<String>(
'cache_key', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _parentTypeMeta =
const VerificationMeta('parentType');
@override
late final GeneratedColumn<String> parentType = GeneratedColumn<String>(
'parent_type', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _parentKeyMeta =
const VerificationMeta('parentKey');
@override
late final GeneratedColumn<String> parentKey = GeneratedColumn<String>(
'parent_key', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _tracksJsonMeta =
const VerificationMeta('tracksJson');
@override
late final GeneratedColumn<String> tracksJson = GeneratedColumn<String>(
'tracks_json', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _lastFetchedMeta =
const VerificationMeta('lastFetched');
@override
late final GeneratedColumn<DateTime> lastFetched = GeneratedColumn<DateTime>(
'last_fetched', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
static const VerificationMeta _lastAccessedMeta =
const VerificationMeta('lastAccessed');
@override
late final GeneratedColumn<DateTime> lastAccessed = GeneratedColumn<DateTime>(
'last_accessed', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
@override
List<GeneratedColumn> get $columns =>
[cacheKey, parentType, parentKey, tracksJson, lastFetched, lastAccessed];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'detail_track_cache';
@override
VerificationContext validateIntegrity(
Insertable<DetailTrackCacheData> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('cache_key')) {
context.handle(_cacheKeyMeta,
cacheKey.isAcceptableOrUnknown(data['cache_key']!, _cacheKeyMeta));
} else if (isInserting) {
context.missing(_cacheKeyMeta);
}
if (data.containsKey('parent_type')) {
context.handle(
_parentTypeMeta,
parentType.isAcceptableOrUnknown(
data['parent_type']!, _parentTypeMeta));
} else if (isInserting) {
context.missing(_parentTypeMeta);
}
if (data.containsKey('parent_key')) {
context.handle(_parentKeyMeta,
parentKey.isAcceptableOrUnknown(data['parent_key']!, _parentKeyMeta));
} else if (isInserting) {
context.missing(_parentKeyMeta);
}
if (data.containsKey('tracks_json')) {
context.handle(
_tracksJsonMeta,
tracksJson.isAcceptableOrUnknown(
data['tracks_json']!, _tracksJsonMeta));
} else if (isInserting) {
context.missing(_tracksJsonMeta);
}
if (data.containsKey('last_fetched')) {
context.handle(
_lastFetchedMeta,
lastFetched.isAcceptableOrUnknown(
data['last_fetched']!, _lastFetchedMeta));
} else if (isInserting) {
context.missing(_lastFetchedMeta);
}
if (data.containsKey('last_accessed')) {
context.handle(
_lastAccessedMeta,
lastAccessed.isAcceptableOrUnknown(
data['last_accessed']!, _lastAccessedMeta));
} else if (isInserting) {
context.missing(_lastAccessedMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {cacheKey};
@override
DetailTrackCacheData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return DetailTrackCacheData(
cacheKey: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}cache_key'])!,
parentType: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}parent_type'])!,
parentKey: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}parent_key'])!,
tracksJson: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}tracks_json'])!,
lastFetched: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}last_fetched'])!,
lastAccessed: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, data['${effectivePrefix}last_accessed'])!,
);
}
@override
$DetailTrackCacheTable createAlias(String alias) {
return $DetailTrackCacheTable(attachedDatabase, alias);
}
}
class DetailTrackCacheData extends DataClass
implements Insertable<DetailTrackCacheData> {
/// Composite key: "{parentType}_{parentKey}"
final String cacheKey;
/// Type: 'album' or 'playlist'
final String parentType;
/// The parent item's cache key (e.g., "provider_itemId")
final String parentKey;
/// Serialized list of tracks as JSON array
final String tracksJson;
/// When this was fetched from API
final DateTime lastFetched;
/// When this was last accessed (for LRU eviction)
final DateTime lastAccessed;
const DetailTrackCacheData(
{required this.cacheKey,
required this.parentType,
required this.parentKey,
required this.tracksJson,
required this.lastFetched,
required this.lastAccessed});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['cache_key'] = Variable<String>(cacheKey);
map['parent_type'] = Variable<String>(parentType);
map['parent_key'] = Variable<String>(parentKey);
map['tracks_json'] = Variable<String>(tracksJson);
map['last_fetched'] = Variable<DateTime>(lastFetched);
map['last_accessed'] = Variable<DateTime>(lastAccessed);
return map;
}
DetailTrackCacheCompanion toCompanion(bool nullToAbsent) {
return DetailTrackCacheCompanion(
cacheKey: Value(cacheKey),
parentType: Value(parentType),
parentKey: Value(parentKey),
tracksJson: Value(tracksJson),
lastFetched: Value(lastFetched),
lastAccessed: Value(lastAccessed),
);
}
factory DetailTrackCacheData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return DetailTrackCacheData(
cacheKey: serializer.fromJson<String>(json['cacheKey']),
parentType: serializer.fromJson<String>(json['parentType']),
parentKey: serializer.fromJson<String>(json['parentKey']),
tracksJson: serializer.fromJson<String>(json['tracksJson']),
lastFetched: serializer.fromJson<DateTime>(json['lastFetched']),
lastAccessed: serializer.fromJson<DateTime>(json['lastAccessed']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'cacheKey': serializer.toJson<String>(cacheKey),
'parentType': serializer.toJson<String>(parentType),
'parentKey': serializer.toJson<String>(parentKey),
'tracksJson': serializer.toJson<String>(tracksJson),
'lastFetched': serializer.toJson<DateTime>(lastFetched),
'lastAccessed': serializer.toJson<DateTime>(lastAccessed),
};
}
DetailTrackCacheData copyWith(
{String? cacheKey,
String? parentType,
String? parentKey,
String? tracksJson,
DateTime? lastFetched,
DateTime? lastAccessed}) =>
DetailTrackCacheData(
cacheKey: cacheKey ?? this.cacheKey,
parentType: parentType ?? this.parentType,
parentKey: parentKey ?? this.parentKey,
tracksJson: tracksJson ?? this.tracksJson,
lastFetched: lastFetched ?? this.lastFetched,
lastAccessed: lastAccessed ?? this.lastAccessed,
);
DetailTrackCacheData copyWithCompanion(DetailTrackCacheCompanion data) {
return DetailTrackCacheData(
cacheKey: data.cacheKey.present ? data.cacheKey.value : this.cacheKey,
parentType:
data.parentType.present ? data.parentType.value : this.parentType,
parentKey: data.parentKey.present ? data.parentKey.value : this.parentKey,
tracksJson:
data.tracksJson.present ? data.tracksJson.value : this.tracksJson,
lastFetched:
data.lastFetched.present ? data.lastFetched.value : this.lastFetched,
lastAccessed: data.lastAccessed.present
? data.lastAccessed.value
: this.lastAccessed,
);
}
@override
String toString() {
return (StringBuffer('DetailTrackCacheData(')
..write('cacheKey: $cacheKey, ')
..write('parentType: $parentType, ')
..write('parentKey: $parentKey, ')
..write('tracksJson: $tracksJson, ')
..write('lastFetched: $lastFetched, ')
..write('lastAccessed: $lastAccessed')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
cacheKey, parentType, parentKey, tracksJson, lastFetched, lastAccessed);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is DetailTrackCacheData &&
other.cacheKey == this.cacheKey &&
other.parentType == this.parentType &&
other.parentKey == this.parentKey &&
other.tracksJson == this.tracksJson &&
other.lastFetched == this.lastFetched &&
other.lastAccessed == this.lastAccessed);
}
class DetailTrackCacheCompanion extends UpdateCompanion<DetailTrackCacheData> {
final Value<String> cacheKey;
final Value<String> parentType;
final Value<String> parentKey;
final Value<String> tracksJson;
final Value<DateTime> lastFetched;
final Value<DateTime> lastAccessed;
final Value<int> rowid;
const DetailTrackCacheCompanion({
this.cacheKey = const Value.absent(),
this.parentType = const Value.absent(),
this.parentKey = const Value.absent(),
this.tracksJson = const Value.absent(),
this.lastFetched = const Value.absent(),
this.lastAccessed = const Value.absent(),
this.rowid = const Value.absent(),
});
DetailTrackCacheCompanion.insert({
required String cacheKey,
required String parentType,
required String parentKey,
required String tracksJson,
required DateTime lastFetched,
required DateTime lastAccessed,
this.rowid = const Value.absent(),
}) : cacheKey = Value(cacheKey),
parentType = Value(parentType),
parentKey = Value(parentKey),
tracksJson = Value(tracksJson),
lastFetched = Value(lastFetched),
lastAccessed = Value(lastAccessed);
static Insertable<DetailTrackCacheData> custom({
Expression<String>? cacheKey,
Expression<String>? parentType,
Expression<String>? parentKey,
Expression<String>? tracksJson,
Expression<DateTime>? lastFetched,
Expression<DateTime>? lastAccessed,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (cacheKey != null) 'cache_key': cacheKey,
if (parentType != null) 'parent_type': parentType,
if (parentKey != null) 'parent_key': parentKey,
if (tracksJson != null) 'tracks_json': tracksJson,
if (lastFetched != null) 'last_fetched': lastFetched,
if (lastAccessed != null) 'last_accessed': lastAccessed,
if (rowid != null) 'rowid': rowid,
});
}
DetailTrackCacheCompanion copyWith(
{Value<String>? cacheKey,
Value<String>? parentType,
Value<String>? parentKey,
Value<String>? tracksJson,
Value<DateTime>? lastFetched,
Value<DateTime>? lastAccessed,
Value<int>? rowid}) {
return DetailTrackCacheCompanion(
cacheKey: cacheKey ?? this.cacheKey,
parentType: parentType ?? this.parentType,
parentKey: parentKey ?? this.parentKey,
tracksJson: tracksJson ?? this.tracksJson,
lastFetched: lastFetched ?? this.lastFetched,
lastAccessed: lastAccessed ?? this.lastAccessed,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (cacheKey.present) {
map['cache_key'] = Variable<String>(cacheKey.value);
}
if (parentType.present) {
map['parent_type'] = Variable<String>(parentType.value);
}
if (parentKey.present) {
map['parent_key'] = Variable<String>(parentKey.value);
}
if (tracksJson.present) {
map['tracks_json'] = Variable<String>(tracksJson.value);
}
if (lastFetched.present) {
map['last_fetched'] = Variable<DateTime>(lastFetched.value);
}
if (lastAccessed.present) {
map['last_accessed'] = Variable<DateTime>(lastAccessed.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('DetailTrackCacheCompanion(')
..write('cacheKey: $cacheKey, ')
..write('parentType: $parentType, ')
..write('parentKey: $parentKey, ')
..write('tracksJson: $tracksJson, ')
..write('lastFetched: $lastFetched, ')
..write('lastAccessed: $lastAccessed, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
@@ -2928,6 +3303,8 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $CachedQueueTable cachedQueue = $CachedQueueTable(this);
late final $HomeRowCacheTable homeRowCache = $HomeRowCacheTable(this);
late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this);
late final $DetailTrackCacheTable detailTrackCache =
$DetailTrackCacheTable(this);
late final Index idxRecentlyPlayedProfile = Index(
'idx_recently_played_profile',
'CREATE INDEX idx_recently_played_profile ON recently_played (profile_username)');
@@ -2958,6 +3335,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
cachedQueue,
homeRowCache,
searchHistory,
detailTrackCache,
idxRecentlyPlayedProfile,
idxRecentlyPlayedProfilePlayed,
idxLibraryCacheType,
@@ -2996,7 +3374,8 @@ final class $$ProfilesTableReferences
$$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
.filter((f) => f.profileUsername.username($_item.username));
.filter((f) => f.profileUsername.username
.sqlEquals($_itemColumn<String>('username')!));
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
return ProcessedTableManager(
@@ -3189,7 +3568,8 @@ class $$ProfilesTableTableManager extends RootTableManager<
getPrefetchedDataCallback: (items) async {
return [
if (recentlyPlayedRefs)
await $_getPrefetchedData(
await $_getPrefetchedData<Profile, $ProfilesTable,
RecentlyPlayedData>(
currentTable: table,
referencedTable: $$ProfilesTableReferences
._recentlyPlayedRefsTable(db),
@@ -3254,8 +3634,10 @@ final class $$RecentlyPlayedTableReferences extends BaseReferences<
db.recentlyPlayed.profileUsername, db.profiles.username));
$$ProfilesTableProcessedTableManager get profileUsername {
final $_column = $_itemColumn<String>('profile_username')!;
final manager = $$ProfilesTableTableManager($_db, $_db.profiles)
.filter((f) => f.username($_item.profileUsername));
.filter((f) => f.username.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
if (item == null) return manager;
return ProcessedTableManager(
@@ -4697,6 +5079,198 @@ typedef $$SearchHistoryTableProcessedTableManager = ProcessedTableManager<
),
SearchHistoryData,
PrefetchHooks Function()>;
typedef $$DetailTrackCacheTableCreateCompanionBuilder
= DetailTrackCacheCompanion Function({
required String cacheKey,
required String parentType,
required String parentKey,
required String tracksJson,
required DateTime lastFetched,
required DateTime lastAccessed,
Value<int> rowid,
});
typedef $$DetailTrackCacheTableUpdateCompanionBuilder
= DetailTrackCacheCompanion Function({
Value<String> cacheKey,
Value<String> parentType,
Value<String> parentKey,
Value<String> tracksJson,
Value<DateTime> lastFetched,
Value<DateTime> lastAccessed,
Value<int> rowid,
});
class $$DetailTrackCacheTableFilterComposer
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
$$DetailTrackCacheTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get cacheKey => $composableBuilder(
column: $table.cacheKey, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get parentType => $composableBuilder(
column: $table.parentType, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get parentKey => $composableBuilder(
column: $table.parentKey, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get tracksJson => $composableBuilder(
column: $table.tracksJson, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get lastFetched => $composableBuilder(
column: $table.lastFetched, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get lastAccessed => $composableBuilder(
column: $table.lastAccessed, builder: (column) => ColumnFilters(column));
}
class $$DetailTrackCacheTableOrderingComposer
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
$$DetailTrackCacheTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get cacheKey => $composableBuilder(
column: $table.cacheKey, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get parentType => $composableBuilder(
column: $table.parentType, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get parentKey => $composableBuilder(
column: $table.parentKey, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get tracksJson => $composableBuilder(
column: $table.tracksJson, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get lastFetched => $composableBuilder(
column: $table.lastFetched, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get lastAccessed => $composableBuilder(
column: $table.lastAccessed,
builder: (column) => ColumnOrderings(column));
}
class $$DetailTrackCacheTableAnnotationComposer
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
$$DetailTrackCacheTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get cacheKey =>
$composableBuilder(column: $table.cacheKey, builder: (column) => column);
GeneratedColumn<String> get parentType => $composableBuilder(
column: $table.parentType, builder: (column) => column);
GeneratedColumn<String> get parentKey =>
$composableBuilder(column: $table.parentKey, builder: (column) => column);
GeneratedColumn<String> get tracksJson => $composableBuilder(
column: $table.tracksJson, builder: (column) => column);
GeneratedColumn<DateTime> get lastFetched => $composableBuilder(
column: $table.lastFetched, builder: (column) => column);
GeneratedColumn<DateTime> get lastAccessed => $composableBuilder(
column: $table.lastAccessed, builder: (column) => column);
}
class $$DetailTrackCacheTableTableManager extends RootTableManager<
_$AppDatabase,
$DetailTrackCacheTable,
DetailTrackCacheData,
$$DetailTrackCacheTableFilterComposer,
$$DetailTrackCacheTableOrderingComposer,
$$DetailTrackCacheTableAnnotationComposer,
$$DetailTrackCacheTableCreateCompanionBuilder,
$$DetailTrackCacheTableUpdateCompanionBuilder,
(
DetailTrackCacheData,
BaseReferences<_$AppDatabase, $DetailTrackCacheTable,
DetailTrackCacheData>
),
DetailTrackCacheData,
PrefetchHooks Function()> {
$$DetailTrackCacheTableTableManager(
_$AppDatabase db, $DetailTrackCacheTable table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$DetailTrackCacheTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$DetailTrackCacheTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$DetailTrackCacheTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<String> cacheKey = const Value.absent(),
Value<String> parentType = const Value.absent(),
Value<String> parentKey = const Value.absent(),
Value<String> tracksJson = const Value.absent(),
Value<DateTime> lastFetched = const Value.absent(),
Value<DateTime> lastAccessed = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) =>
DetailTrackCacheCompanion(
cacheKey: cacheKey,
parentType: parentType,
parentKey: parentKey,
tracksJson: tracksJson,
lastFetched: lastFetched,
lastAccessed: lastAccessed,
rowid: rowid,
),
createCompanionCallback: ({
required String cacheKey,
required String parentType,
required String parentKey,
required String tracksJson,
required DateTime lastFetched,
required DateTime lastAccessed,
Value<int> rowid = const Value.absent(),
}) =>
DetailTrackCacheCompanion.insert(
cacheKey: cacheKey,
parentType: parentType,
parentKey: parentKey,
tracksJson: tracksJson,
lastFetched: lastFetched,
lastAccessed: lastAccessed,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$DetailTrackCacheTableProcessedTableManager = ProcessedTableManager<
_$AppDatabase,
$DetailTrackCacheTable,
DetailTrackCacheData,
$$DetailTrackCacheTableFilterComposer,
$$DetailTrackCacheTableOrderingComposer,
$$DetailTrackCacheTableAnnotationComposer,
$$DetailTrackCacheTableCreateCompanionBuilder,
$$DetailTrackCacheTableUpdateCompanionBuilder,
(
DetailTrackCacheData,
BaseReferences<_$AppDatabase, $DetailTrackCacheTable,
DetailTrackCacheData>
),
DetailTrackCacheData,
PrefetchHooks Function()>;
class $AppDatabaseManager {
final _$AppDatabase _db;
@@ -4719,4 +5293,6 @@ class $AppDatabaseManager {
$$HomeRowCacheTableTableManager(_db, _db.homeRowCache);
$$SearchHistoryTableTableManager get searchHistory =>
$$SearchHistoryTableTableManager(_db, _db.searchHistory);
$$DetailTrackCacheTableTableManager get detailTrackCache =>
$$DetailTrackCacheTableTableManager(_db, _db.detailTrackCache);
}
+11 -4
View File
@@ -52,7 +52,6 @@
"recentlyPlayed": "Zuletzt gespielt",
"discoverArtists": "Kunstler entdecken",
"discoverAlbums": "Alben entdecken",
"playlistMixes": "Playlists entdecken",
"continueListening": "Weiterhoren",
"discoverAudiobooks": "Horbücher entdecken",
"discoverSeries": "Serien entdecken",
@@ -172,7 +171,6 @@
"noArtistsFound": "Keine Künstler gefunden",
"noTracksFound": "Keine Songs gefunden",
"noPlaylistsFound": "Keine Wiedergabelisten gefunden",
"noPlaylistMixesFound": "Keine Playlist-Mixes gefunden",
"queueIsEmpty": "Warteschlange ist leer",
"noResultsFound": "Keine Ergebnisse gefunden",
"refresh": "Aktualisieren",
@@ -218,7 +216,6 @@
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
"showPlaylistMixes": "Playlist-Mixes von externen Anbietern anzeigen",
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
@@ -298,5 +295,15 @@
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
"holdToSync": "Gedrückt halten zum Sync",
"swipeToAdjustVolume": "Wischen für Lautstärke"
"swipeToAdjustVolume": "Wischen für Lautstärke",
"aboutAndSupport": "Über & Unterstützung",
"supportDevelopment": "Entwicklung unterstützen",
"ensembleIsUnofficial": "Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.",
"sponsorOnGitHub": "Auf GitHub sponsoren",
"buyMeAKoFi": "Kauf mir einen Ko-fi",
"aboutEnsemble": "Über Ensemble",
"version": "Version",
"visitGitHubRepo": "GitHub-Repository besuchen",
"reportABug": "Einen Fehler melden"
}
+83 -9
View File
@@ -221,9 +221,34 @@
"description": "Home screen section title"
},
"playlistMixes": "Discover Playlists",
"@playlistMixes": {
"description": "Home screen section title"
"discoveryMixes": "Discovery Mixes",
"@discoveryMixes": {
"description": "Provider discovery mixes (e.g., Tidal playlists)"
},
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
"@discoveryMixesDescription": {
"description": "Description for discovery mixes setting"
},
"discoveryRows": "Discovery rows from Music Assistant",
"@discoveryRows": {
"description": "Label for discovery rows section"
},
"refreshDiscoveryRows": "Refresh available rows",
"@refreshDiscoveryRows": {
"description": "Button to refresh discovery rows"
},
"noDiscoveryRows": "No discovery rows available",
"@noDiscoveryRows": {
"description": "Empty state when no discovery rows available"
},
"discoveryRowBadge": "Music Assistant",
"@discoveryRowBadge": {
"description": "Badge label for discovery rows"
},
"continueListening": "Continue Listening",
@@ -930,11 +955,6 @@
"description": "Empty playlists"
},
"noPlaylistMixesFound": "No playlist mixes found",
"@noPlaylistMixesFound": {
"description": "Empty playlist mixes"
},
"queueIsEmpty": "Queue is empty",
"@queueIsEmpty": {
"description": "Empty queue"
@@ -1095,7 +1115,6 @@
"showRecentlyPlayedAlbums": "Show recently played albums",
"showRandomArtists": "Show random artists to discover",
"showRandomAlbums": "Show random albums to discover",
"showPlaylistMixes": "Show playlist mixes from external providers",
"showAudiobooksInProgress": "Show audiobooks in progress",
"showRandomAudiobooks": "Show random audiobooks to discover",
"showRandomSeries": "Show random audiobook series to discover",
@@ -1694,5 +1713,60 @@
"selectPlayerToAddToQueue": "Select player to add to queue",
"@selectPlayerToAddToQueue": {
"description": "Hint for selecting player to add to queue"
},
"aboutAndSupport": "About & Support",
"@aboutAndSupport": {
"description": "About and support section title"
},
"supportDevelopment": "Support Development",
"@supportDevelopment": {
"description": "Support development section"
},
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.",
"@ensembleIsUnofficial": {
"description": "Description about Ensemble being unofficial"
},
"sponsorOnGitHub": "Sponsor on GitHub",
"@sponsorOnGitHub": {
"description": "GitHub Sponsors button label"
},
"buyMeAKoFi": "Buy me a Ko-fi",
"@buyMeAKoFi": {
"description": "Ko-fi donation button label"
},
"aboutEnsemble": "About Ensemble",
"@aboutEnsemble": {
"description": "About Ensemble section"
},
"version": "Version",
"@version": {
"description": "Version label"
},
"visitGitHubRepo": "Visit GitHub Repository",
"@visitGitHubRepo": {
"description": "Link to GitHub repo"
},
"reportABug": "Report a Bug",
"@reportABug": {
"description": "Link to report issues"
},
"noRowsEnabled": "No rows enabled",
"@noRowsEnabled": {
"description": "Empty state when no home screen rows are enabled"
},
"pullToRefreshHint": "Pull down to refresh or enable rows in settings",
"@pullToRefreshHint": {
"description": "Hint when home screen is empty"
}
}
+11 -4
View File
@@ -51,7 +51,6 @@
"recentlyPlayed": "Reproducido recientemente",
"discoverArtists": "Descubrir artistas",
"discoverAlbums": "Descubrir álbumes",
"playlistMixes": "Descubrir listas de reproducción",
"continueListening": "Continuar escuchando",
"discoverAudiobooks": "Descubrir audiolibros",
"discoverSeries": "Descubrir series",
@@ -321,7 +320,6 @@
"noArtistsFound": "No se encontraron artistas",
"noTracksFound": "No se encontraron canciones",
"noPlaylistsFound": "No se encontraron listas de reproducción",
"noPlaylistMixesFound": "No se encontraron mezclas de playlists",
"queueIsEmpty": "La cola está vacía",
"noResultsFound": "No se encontraron resultados",
"refresh": "Actualizar",
@@ -374,7 +372,6 @@
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
"showPlaylistMixes": "Mostrar mezclas de playlists de proveedores externos",
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
@@ -494,5 +491,15 @@
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado para sync",
"swipeToAdjustVolume": "Desliza para ajustar volumen"
"swipeToAdjustVolume": "Desliza para ajustar volumen",
"aboutAndSupport": "Acerca de y Soporte",
"supportDevelopment": "Apoyar el Desarrollo",
"ensembleIsUnofficial": "Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.",
"sponsorOnGitHub": "Patrocina en GitHub",
"buyMeAKoFi": "Cómprame un Ko-fi",
"aboutEnsemble": "Acerca de Ensemble",
"version": "Versión",
"visitGitHubRepo": "Visitar Repositorio GitHub",
"reportABug": "Reportar un Error"
}
+11 -12
View File
@@ -221,11 +221,6 @@
"description": "Home screen section title"
},
"playlistMixes": "Découvrir des playlists",
"@playlistMixes": {
"description": "Home screen section title"
},
"continueListening": "Continuer l'écoute",
"@continueListening": {
"description": "Home screen section title"
@@ -920,11 +915,6 @@
"description": "Empty playlists"
},
"noPlaylistMixesFound": "Aucun mix de playlists trouve",
"@noPlaylistMixesFound": {
"description": "Empty playlist mixes"
},
"queueIsEmpty": "La file d'attente est vide",
"@queueIsEmpty": {
"description": "Empty queue"
@@ -1081,7 +1071,6 @@
"showRecentlyPlayedAlbums": "Afficher les albums écoutes recemment",
"showRandomArtists": "Afficher des artistes aléatoires a découvrir",
"showRandomAlbums": "Afficher des albums aléatoires a découvrir",
"showPlaylistMixes": "Afficher les mixes de playlists provenant de fournisseurs externes",
"showAudiobooksInProgress": "Afficher les livres audio en cours",
"showRandomAudiobooks": "Afficher des livres audio aléatoires a découvrir",
"showRandomSeries": "Afficher des séries de livres audio aléatoires a découvrir",
@@ -1407,5 +1396,15 @@
"type": "String"
}
}
}
},
"aboutAndSupport": "À propos et Support",
"supportDevelopment": "Soutenir le Développement",
"ensembleIsUnofficial": "Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.",
"sponsorOnGitHub": "Sponsoriser sur GitHub",
"buyMeAKoFi": "Offrez-moi un Ko-fi",
"aboutEnsemble": "À propos d'Ensemble",
"version": "Version",
"visitGitHubRepo": "Visiter le Dépôt GitHub",
"reportABug": "Signaler un Bug"
}
+99 -15
View File
@@ -359,11 +359,41 @@ abstract class S {
/// **'Discover Albums'**
String get discoverAlbums;
/// Home screen section title
/// Provider discovery mixes (e.g., Tidal playlists)
///
/// In en, this message translates to:
/// **'Discover Playlists'**
String get playlistMixes;
/// **'Discovery Mixes'**
String get discoveryMixes;
/// Description for discovery mixes setting
///
/// In en, this message translates to:
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
String get discoveryMixesDescription;
/// Label for discovery rows section
///
/// In en, this message translates to:
/// **'Discovery rows from Music Assistant'**
String get discoveryRows;
/// Button to refresh discovery rows
///
/// In en, this message translates to:
/// **'Refresh available rows'**
String get refreshDiscoveryRows;
/// Empty state when no discovery rows available
///
/// In en, this message translates to:
/// **'No discovery rows available'**
String get noDiscoveryRows;
/// Badge label for discovery rows
///
/// In en, this message translates to:
/// **'Music Assistant'**
String get discoveryRowBadge;
/// Home screen section title
///
@@ -1055,12 +1085,6 @@ abstract class S {
/// **'No playlists found'**
String get noPlaylistsFound;
/// Empty playlist mixes
///
/// In en, this message translates to:
/// **'No playlist mixes found'**
String get noPlaylistMixesFound;
/// Empty queue
///
/// In en, this message translates to:
@@ -1313,12 +1337,6 @@ abstract class S {
/// **'Show random albums to discover'**
String get showRandomAlbums;
/// No description provided for @showPlaylistMixes.
///
/// In en, this message translates to:
/// **'Show playlist mixes from external providers'**
String get showPlaylistMixes;
/// No description provided for @showAudiobooksInProgress.
///
/// In en, this message translates to:
@@ -2200,6 +2218,72 @@ abstract class S {
/// In en, this message translates to:
/// **'Select player to add to queue'**
String get selectPlayerToAddToQueue;
/// About and support section title
///
/// In en, this message translates to:
/// **'About & Support'**
String get aboutAndSupport;
/// Support development section
///
/// In en, this message translates to:
/// **'Support Development'**
String get supportDevelopment;
/// Description about Ensemble being unofficial
///
/// In en, this message translates to:
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'**
String get ensembleIsUnofficial;
/// GitHub Sponsors button label
///
/// In en, this message translates to:
/// **'Sponsor on GitHub'**
String get sponsorOnGitHub;
/// Ko-fi donation button label
///
/// In en, this message translates to:
/// **'Buy me a Ko-fi'**
String get buyMeAKoFi;
/// About Ensemble section
///
/// In en, this message translates to:
/// **'About Ensemble'**
String get aboutEnsemble;
/// Version label
///
/// In en, this message translates to:
/// **'Version'**
String get version;
/// Link to GitHub repo
///
/// In en, this message translates to:
/// **'Visit GitHub Repository'**
String get visitGitHubRepo;
/// Link to report issues
///
/// In en, this message translates to:
/// **'Report a Bug'**
String get reportABug;
/// Empty state when no home screen rows are enabled
///
/// In en, this message translates to:
/// **'No rows enabled'**
String get noRowsEnabled;
/// Hint when home screen is empty
///
/// In en, this message translates to:
/// **'Pull down to refresh or enable rows in settings'**
String get pullToRefreshHint;
}
class _SDelegate extends LocalizationsDelegate<S> {
+52 -8
View File
@@ -152,7 +152,23 @@ class SDe extends S {
String get discoverAlbums => 'Alben entdecken';
@override
String get playlistMixes => 'Playlists entdecken';
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Weiterhoren';
@@ -554,9 +570,6 @@ class SDe extends S {
@override
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
@override
String get noPlaylistMixesFound => 'Keine Playlist-Mixes gefunden';
@override
String get queueIsEmpty => 'Warteschlange ist leer';
@@ -688,10 +701,6 @@ class SDe extends S {
@override
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
@override
String get showPlaylistMixes =>
'Playlist-Mixes von externen Anbietern anzeigen';
@override
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@@ -1196,4 +1205,39 @@ class SDe extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'Über & Unterstützung';
@override
String get supportDevelopment => 'Entwicklung unterstützen';
@override
String get ensembleIsUnofficial =>
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
@override
String get sponsorOnGitHub => 'Auf GitHub sponsoren';
@override
String get buyMeAKoFi => 'Kauf mir einen Ko-fi';
@override
String get aboutEnsemble => 'Über Ensemble';
@override
String get version => 'Version';
@override
String get visitGitHubRepo => 'GitHub-Repository besuchen';
@override
String get reportABug => 'Einen Fehler melden';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+52 -7
View File
@@ -149,7 +149,23 @@ class SEn extends S {
String get discoverAlbums => 'Discover Albums';
@override
String get playlistMixes => 'Discover Playlists';
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Continue Listening';
@@ -549,9 +565,6 @@ class SEn extends S {
@override
String get noPlaylistsFound => 'No playlists found';
@override
String get noPlaylistMixesFound => 'No playlist mixes found';
@override
String get queueIsEmpty => 'Queue is empty';
@@ -682,9 +695,6 @@ class SEn extends S {
@override
String get showRandomAlbums => 'Show random albums to discover';
@override
String get showPlaylistMixes => 'Show playlist mixes from external providers';
@override
String get showAudiobooksInProgress => 'Show audiobooks in progress';
@@ -1186,4 +1196,39 @@ class SEn extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'About & Support';
@override
String get supportDevelopment => 'Support Development';
@override
String get ensembleIsUnofficial =>
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
@override
String get sponsorOnGitHub => 'Sponsor on GitHub';
@override
String get buyMeAKoFi => 'Buy me a Ko-fi';
@override
String get aboutEnsemble => 'About Ensemble';
@override
String get version => 'Version';
@override
String get visitGitHubRepo => 'Visit GitHub Repository';
@override
String get reportABug => 'Report a Bug';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+52 -8
View File
@@ -153,7 +153,23 @@ class SEs extends S {
String get discoverAlbums => 'Descubrir álbumes';
@override
String get playlistMixes => 'Descubrir listas de reproducción';
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Continuar escuchando';
@@ -557,9 +573,6 @@ class SEs extends S {
@override
String get noPlaylistsFound => 'No se encontraron listas de reproducción';
@override
String get noPlaylistMixesFound => 'No se encontraron mezclas de playlists';
@override
String get queueIsEmpty => 'La cola está vacía';
@@ -693,10 +706,6 @@ class SEs extends S {
@override
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
@override
String get showPlaylistMixes =>
'Mostrar mezclas de playlists de proveedores externos';
@override
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@@ -1204,4 +1213,39 @@ class SEs extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'Acerca de y Soporte';
@override
String get supportDevelopment => 'Apoyar el Desarrollo';
@override
String get ensembleIsUnofficial =>
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
@override
String get sponsorOnGitHub => 'Patrocina en GitHub';
@override
String get buyMeAKoFi => 'Cómprame un Ko-fi';
@override
String get aboutEnsemble => 'Acerca de Ensemble';
@override
String get version => 'Versión';
@override
String get visitGitHubRepo => 'Visitar Repositorio GitHub';
@override
String get reportABug => 'Reportar un Error';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+52 -8
View File
@@ -154,7 +154,23 @@ class SFr extends S {
String get discoverAlbums => 'Découvrir des albums';
@override
String get playlistMixes => 'Découvrir des playlists';
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override
String get continueListening => 'Continuer l\'écoute';
@@ -558,9 +574,6 @@ class SFr extends S {
@override
String get noPlaylistsFound => 'Aucune playlist trouvee';
@override
String get noPlaylistMixesFound => 'Aucun mix de playlists trouve';
@override
String get queueIsEmpty => 'La file d\'attente est vide';
@@ -695,10 +708,6 @@ class SFr extends S {
@override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
@override
String get showPlaylistMixes =>
'Afficher les mixes de playlists provenant de fournisseurs externes';
@override
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@@ -1213,4 +1222,39 @@ class SFr extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'À propos et Support';
@override
String get supportDevelopment => 'Soutenir le Développement';
@override
String get ensembleIsUnofficial =>
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
@override
String get sponsorOnGitHub => 'Sponsoriser sur GitHub';
@override
String get buyMeAKoFi => 'Offrez-moi un Ko-fi';
@override
String get aboutEnsemble => 'À propos d\'Ensemble';
@override
String get version => 'Version';
@override
String get visitGitHubRepo => 'Visiter le Dépôt GitHub';
@override
String get reportABug => 'Signaler un Bug';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+134 -78
View File
@@ -31,82 +31,85 @@ late MassivAudioHandler audioHandler;
// Global debug logger instance
final _logger = DebugLogger();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance
await SettingsService.initialize();
_logger.log('⚡ SharedPreferences cached');
// Initialize local database
await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized');
// Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName();
// Migrate credentials to secure storage (one-time for existing users)
await SettingsService.migrateToSecureStorage();
_logger.log('🔐 Secure storage migration complete');
// Load library from cache for instant startup
await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded');
// Create auth manager for streaming headers
final authManager = AuthManager();
// Initialize audio_service with our custom handler
audioHandler = await AudioService.init(
builder: () => MassivAudioHandler(authManager: authManager),
config: const AudioServiceConfig(
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
androidNotificationChannelName: 'Ensemble Audio',
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
androidNotificationIcon: 'drawable/ic_notification',
androidShowNotificationBadge: false,
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
),
);
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
// Set preferred orientations
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Set initial system UI overlay style based on platform brightness
// SystemUIWrapper will update this dynamically when theme changes
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
final isDark = platformBrightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
// Set up Flutter error boundary to catch and log widget build errors
FlutterError.onError = (FlutterErrorDetails details) {
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
// Still report to Flutter's default handler in debug mode
FlutterError.presentError(details);
};
// Catch errors from the platform dispatcher (platform channel errors)
PlatformDispatcher.instance.onError = (error, stack) {
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
return true; // Handled
};
// Wrap app in zone to catch async errors
void main() {
// All initialization must run inside the same zone as runApp() to avoid
// "Zone mismatch" errors from Flutter's binding system
runZonedGuarded(
() => runApp(const MusicAssistantApp()),
() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance
await SettingsService.initialize();
_logger.log('⚡ SharedPreferences cached');
// Initialize local database
await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized');
// Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName();
// Migrate credentials to secure storage (one-time for existing users)
await SettingsService.migrateToSecureStorage();
_logger.log('🔐 Secure storage migration complete');
// Load library from cache for instant startup
await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded');
// Create auth manager for streaming headers
final authManager = AuthManager();
// Initialize audio_service with our custom handler
audioHandler = await AudioService.init(
builder: () => MassivAudioHandler(authManager: authManager),
config: const AudioServiceConfig(
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
androidNotificationChannelName: 'Ensemble Audio',
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
androidNotificationIcon: 'drawable/ic_notification',
androidShowNotificationBadge: false,
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
),
);
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
// Set preferred orientations
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Set initial system UI overlay style based on platform brightness
// SystemUIWrapper will update this dynamically when theme changes
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
final isDark = platformBrightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
),
);
// Set up Flutter error boundary to catch and log widget build errors
FlutterError.onError = (FlutterErrorDetails details) {
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
// Still report to Flutter's default handler in debug mode
FlutterError.presentError(details);
};
// Catch errors from the platform dispatcher (platform channel errors)
PlatformDispatcher.instance.onError = (error, stack) {
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
return true; // Handled
};
runApp(const MusicAssistantApp());
},
(error, stackTrace) {
_logger.error('Uncaught async error: $error', context: 'ZoneError');
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
@@ -128,6 +131,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub;
StreamSubscription? _absoluteVolumeSub;
String? _lastSelectedPlayerId;
String? _builtinPlayerId;
@@ -140,6 +144,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider();
_localeProvider = LocaleProvider();
audioHandler.setProvider(_musicProvider);
WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception
@@ -158,6 +163,12 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
_adjustVolume(-_volumeStep);
});
// Listen for absolute volume changes from lockscreen/background
// (ContentObserver on system STREAM_MUSIC volume)
_absoluteVolumeSub = _hardwareVolumeService.onAbsoluteVolumeChange.listen((volume) {
_setAbsoluteVolume(volume);
});
} catch (e, stack) {
_logger.error('Hardware volume init failed', context: 'VolumeInit', error: e, stackTrace: stack);
}
@@ -173,18 +184,41 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
}
/// Enable/disable volume button interception based on selected player.
/// Also manages the volume observer for lockscreen volume routing.
Future<void> _updateVolumeInterception() async {
final player = _musicProvider.selectedPlayer;
final isBuiltinPlayer = _builtinPlayerId != null &&
_musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
player?.playerId == _builtinPlayerId;
await _hardwareVolumeService.setIntercepting(!isBuiltinPlayer);
// Start/stop volume observer for lockscreen volume changes.
// When a remote/group player is active, observe system volume changes
// so lockscreen hardware buttons route to the MA player.
if (!isBuiltinPlayer && player != null) {
// Get the effective volume to sync the system volume HUD
final effectiveVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
: player.volume;
await _hardwareVolumeService.startVolumeObserver(initialVolume: effectiveVolume);
} else {
await _hardwareVolumeService.stopVolumeObserver();
}
}
Future<void> _adjustVolume(int delta) async {
final player = _musicProvider.selectedPlayer;
if (player == null) return;
final newVolume = (player.volume + delta).clamp(0, 100);
if (newVolume != player.volume) {
// Use effective volume for group players (player.volume returns 0
// because MA API returns null for group player volume_level)
final currentVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
: player.volume;
final newVolume = (currentVolume + delta).clamp(0, 100);
if (newVolume != currentVolume) {
try {
await _musicProvider.setVolume(player.playerId, newVolume);
} catch (e) {
@@ -193,11 +227,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
}
}
/// Handle absolute volume change from lockscreen/background.
/// Called when the ContentObserver detects a system volume change
/// while a remote/group player is active.
Future<void> _setAbsoluteVolume(int volume) async {
final player = _musicProvider.selectedPlayer;
if (player == null) return;
// Don't route to builtin player - that's handled by the system directly
if (_builtinPlayerId != null && player.playerId == _builtinPlayerId) return;
try {
await _musicProvider.setVolume(player.playerId, volume.clamp(0, 100));
} catch (e) {
_logger.error('Lockscreen volume change failed', context: 'VolumeControl', error: e);
}
}
@override
void dispose() {
_musicProvider.removeListener(_onProviderChanged);
_volumeUpSub?.cancel();
_volumeDownSub?.cancel();
_absoluteVolumeSub?.cancel();
_hardwareVolumeService.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
@@ -484,6 +536,9 @@ class _AppStartupState extends State<AppStartup> {
Future<void> _checkAndConnect() async {
final serverUrl = await SettingsService.getServerUrl();
// DEBUG: Log where server URL is coming from
_logger.log('🐛 DEBUG: Server URL from SharedPreferences: $serverUrl');
if (!mounted) return;
setState(() {
@@ -492,6 +547,7 @@ class _AppStartupState extends State<AppStartup> {
// If we have a saved server URL, attempt auto-connection
if (serverUrl != null && serverUrl.isNotEmpty) {
_logger.log('🐛 DEBUG: Auto-connecting because server URL exists');
setState(() {
_isConnecting = true;
});
-32
View File
@@ -10,29 +10,6 @@ enum MediaType {
podcastEpisode,
}
/// Parse a media item JSON map into the appropriate model type.
MediaItem parseMediaItemJson(Map<String, dynamic> json) {
final mediaTypeStr = (json['media_type'] as String?)?.toLowerCase();
switch (mediaTypeStr) {
case 'artist':
return Artist.fromJson(json);
case 'album':
return Album.fromJson(json);
case 'track':
return Track.fromJson(json);
case 'playlist':
return Playlist.fromJson(json);
case 'audiobook':
return Audiobook.fromJson(json);
case 'radio':
return MediaItem.fromJson(json);
case 'podcast':
case 'podcast_episode':
default:
return MediaItem.fromJson(json);
}
}
class ProviderMapping {
final String itemId;
final String providerDomain;
@@ -379,15 +356,6 @@ class Playlist extends MediaItem {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
if (owner != null) json['owner'] = owner;
if (isEditable != null) json['is_editable'] = isEditable;
if (trackCount != null) json['track_count'] = trackCount;
return json;
}
}
class Chapter {
+27 -1
View File
@@ -15,11 +15,15 @@ class RecommendationFolder {
required this.items,
});
/// Unique ID for use in row ordering and settings
/// Uses 'discovery:' prefix to distinguish from static row IDs
String get rowId => 'discovery:$itemId';
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
final itemsJson = json['items'] as List<dynamic>?;
final parsedItems = itemsJson
?.whereType<Map<String, dynamic>>()
.map(parseMediaItemJson)
.map(_parseMediaItemJson)
.toList() ?? <MediaItem>[];
return RecommendationFolder(
@@ -41,3 +45,25 @@ class RecommendationFolder {
};
}
}
/// Polymorphic parser for media items in recommendation folders
/// Handles heterogeneous items (playlists, radio, etc.)
MediaItem _parseMediaItemJson(Map<String, dynamic> json) {
final mediaType = json['media_type'] as String? ?? '';
switch (mediaType) {
case 'playlist':
return Playlist.fromJson(json);
case 'album':
return Album.fromJson(json);
case 'artist':
return Artist.fromJson(json);
case 'track':
return Track.fromJson(json);
case 'audiobook':
return Audiobook.fromJson(json);
default:
// For radio, podcast, podcast_episode - use MediaItem base class
return MediaItem.fromJson(json);
}
}
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -17,14 +17,14 @@ class NavigationProvider extends ChangeNotifier {
if (previousIndex != index) {
_selectedIndex = index;
notifyListeners();
}
// Trigger search focus callback when switching to search tab
if (index == 2 && onSearchTabSelected != null) {
// Use post-frame callback to ensure SearchScreen is built first
WidgetsBinding.instance.addPostFrameCallback((_) {
onSearchTabSelected?.call();
});
}
// Trigger search focus callback when switching to or re-tapping search tab
if (index == 2 && onSearchTabSelected != null) {
// Use post-frame callback to ensure SearchScreen is built first
WidgetsBinding.instance.addPostFrameCallback((_) {
onSearchTabSelected?.call();
});
}
}
+30 -5
View File
@@ -573,6 +573,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
widget.album.provider,
widget.album.itemId,
forceRefresh: cachedTracks != null, // Force refresh if we had cache
album: widget.album,
);
// 3. Update if we got different data
@@ -847,7 +848,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -935,7 +938,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -972,7 +975,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
Spacing.vGap16,
Spacing.vGap8,
// Album Description
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
InkWell(
@@ -1109,7 +1112,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
],
),
const SizedBox(height: 24),
const SizedBox(height: 12),
],
),
),
@@ -1216,7 +1219,29 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
);
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background,
],
),
),
),
),
),
],
);
},
),
),
+51 -12
View File
@@ -680,6 +680,19 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
_isLoading = false;
});
}
} else {
// No cache yet - show library albums from memory instantly (no API call)
final memoryAlbums = provider.getArtistAlbumsFromLibrary(widget.artist.name);
if (memoryAlbums.isNotEmpty && mounted) {
final libraryAlbums = memoryAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = memoryAlbums.where((a) => !a.inLibrary).toList();
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
_sortAlbums();
_isLoading = false;
});
}
}
// 2. Fetch fresh data in background (silent refresh)
@@ -690,13 +703,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
);
if (mounted && allAlbums.isNotEmpty) {
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
// Check if data actually changed
final albumsChanged = _albums.length != allAlbums.where((a) => a.inLibrary).length;
if (albumsChanged || _albums.isEmpty) {
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
final changed = _albums.length != libraryAlbums.length ||
_providerAlbums.length != providerOnlyAlbums.length;
if (changed) {
setState(() {
_albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums;
@@ -882,11 +896,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
backgroundColor: colorScheme.background,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
// Responsive cover size: 50% of screen width, clamped between 140-200 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
final expandedHeight = coverSize + 45;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -942,7 +958,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -959,7 +975,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
),
),
),
Spacing.vGap16,
Spacing.vGap8,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell(
onTap: () {
@@ -1157,7 +1173,30 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
],
],
],
);
),
// Bottom fade gradient to mask content behind mini player
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background,
],
),
),
),
),
),
],
);
},
),
),
+88 -60
View File
@@ -14,6 +14,7 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'audiobook_detail_screen.dart';
import '../widgets/provider_icon.dart';
@@ -297,11 +298,13 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
// Responsive cover size: 50% of screen width, clamped between 140-200 (matches artist detail)
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
final expandedHeight = coverSize + 25;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -315,49 +318,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
},
color: colorScheme.onSurface,
),
actions: [
// Three-dot menu with sort/view options
Padding(
padding: const EdgeInsets.only(right: 8),
child: SizedBox(
height: 40,
width: 40,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
isFavorite: false,
isInLibrary: false,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
viewMode: _viewMode,
onCycleView: _cycleViewMode,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 20,
color: Colors.white70,
),
),
),
),
),
],
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -410,22 +370,68 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
widget.authorName,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
// Author info on the left
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.authorName,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
// Three-dot Menu Button on the right
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
isFavorite: false,
isInLibrary: false,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
viewMode: _viewMode,
onCycleView: _cycleViewMode,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
),
),
),
],
@@ -448,6 +454,28 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
_buildAudiobookSliver(),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
+72 -31
View File
@@ -68,53 +68,70 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
// Load full audiobook details with chapters
_loadAudiobookDetails();
});
// Defer detail loading until after hero animation completes
// This prevents setState with new data during animation → jank
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadAudiobookDetails();
}
});
});
}
Future<void> _loadAudiobookDetails() async {
if (_isLoadingDetails) return;
setState(() {
_isLoadingDetails = true;
});
final maProvider = context.read<MusicAssistantProvider>();
// 1. Show cached data immediately (if available)
final cached = maProvider.getCachedAudiobookDetail(
widget.audiobook.provider,
widget.audiobook.itemId,
);
if (cached != null && mounted) {
_logger.log('📚 Showing cached audiobook: ${cached.name}, chapters: ${cached.chapters?.length ?? 0}');
setState(() {
_fullAudiobook = cached;
_isLoadingDetails = false;
});
} else {
setState(() {
_isLoadingDetails = true;
});
}
// 2. Fetch fresh data in background
try {
final maProvider = context.read<MusicAssistantProvider>();
final fullBook = await maProvider.getAudiobookDetailsWithCache(
widget.audiobook.provider,
widget.audiobook.itemId,
forceRefresh: cached != null,
);
if (maProvider.api != null) {
final fullBook = await maProvider.api!.getAudiobookDetails(
widget.audiobook.provider,
widget.audiobook.itemId,
);
if (mounted && fullBook != null) {
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
setState(() {
_fullAudiobook = fullBook;
});
}
}
if (mounted) {
if (mounted && fullBook != null) {
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
setState(() {
_fullAudiobook = fullBook;
_isLoadingDetails = false;
});
}
} catch (e) {
_logger.log('Error loading audiobook details: $e');
if (mounted) {
setState(() {
_isLoadingDetails = false;
});
}
}
if (mounted && _isLoadingDetails) {
setState(() {
_isLoadingDetails = false;
});
}
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
final imageUrl = maProvider.getImageUrl(_audiobook, size: 512);
if (imageUrl == null) return;
try {
@@ -480,7 +497,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
final providerImageUrl = maProvider.getImageUrl(_audiobook, size: 512);
// Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
@@ -523,7 +540,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -652,7 +671,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -929,7 +948,29 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
],
);
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
),
+130 -70
View File
@@ -44,24 +44,30 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
// Extracted colors from book covers
List<Color> _extractedColors = [];
// Cover URLs - start with initial covers, update when books load
// Cover URLs - set once from initialCovers or first book load, then frozen
List<String> _coverUrls = [];
// Cached cover widget - built once, never rebuilt
Widget? _cachedCoverWidget;
@override
void initState() {
super.initState();
// Use initial covers immediately for smooth Hero animation
if (widget.initialCovers != null && widget.initialCovers!.isNotEmpty) {
_coverUrls = List.from(widget.initialCovers!);
// Start color extraction from initial covers
_extractColorsFromUrls(widget.initialCovers!);
}
_loadViewPreferences();
// Defer loading until after first frame to ensure UI renders first
// Defer color extraction and loading until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_loadSeriesBooks();
}
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
if (_coverUrls.isNotEmpty) {
_extractColorsFromUrls(_coverUrls);
}
_loadSeriesBooks();
}
});
});
}
@@ -158,35 +164,68 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Future<void> _loadSeriesBooks() async {
_logger.log('📚 SeriesScreen _loadSeriesBooks START');
try {
final maProvider = context.read<MusicAssistantProvider>();
// 1. Show cached data immediately (if available)
final cached = maProvider.getCachedSeriesAudiobooks(widget.series.id);
if (cached != null && cached.isNotEmpty && mounted) {
_logger.log('📚 Showing ${cached.length} cached series books');
_applyBooks(cached, maProvider);
} else {
setState(() {
_isLoading = true;
_error = null;
});
_logger.log('📚 SeriesScreen setState done, getting provider...');
final maProvider = context.read<MusicAssistantProvider>();
_logger.log('📚 SeriesScreen got provider, api=${maProvider.api != null}');
}
// 2. Fetch fresh data in background
try {
if (maProvider.api == null) {
setState(() {
_error = S.of(context)!.notConnected;
_isLoading = false;
});
if (cached == null || cached.isEmpty) {
setState(() {
_error = S.of(context)!.notConnected;
_isLoading = false;
});
}
return;
}
_logger.log('📚 SeriesScreen calling getSeriesAudiobooks: path=${widget.series.id}');
final books = await maProvider.api!.getSeriesAudiobooks(widget.series.id);
final books = await maProvider.getSeriesAudiobooksWithCache(
widget.series.id,
forceRefresh: cached != null && cached.isNotEmpty,
);
_logger.log('📚 SeriesScreen got ${books.length} books');
// Debug: Log sequence data for each book
for (final book in books) {
_logger.log('📚 Book: "${book.name}" | position=${book.position} | browseOrder=${book.browseOrder} | sortName=${book.sortName} | seq=${book.seriesSequence} | metadata.series=${book.metadata?['series']}');
if (mounted && books.isNotEmpty) {
final changed = _audiobooks.length != books.length;
if (changed || _audiobooks.isEmpty) {
_applyBooks(books, maProvider);
}
}
} catch (e, stack) {
_logger.log('📚 SeriesScreen error: $e');
_logger.log('📚 SeriesScreen stack: $stack');
if (mounted && _audiobooks.isEmpty) {
setState(() {
_error = 'Failed to load books: $e';
_isLoading = false;
});
}
}
if (mounted) {
// Update cover URLs from loaded books
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
void _applyBooks(List<Audiobook> books, MusicAssistantProvider maProvider) {
// Only populate covers if we have none yet (initialCovers was empty)
final needsCovers = _coverUrls.isEmpty;
setState(() {
_audiobooks = books;
_sortAudiobooks();
if (needsCovers) {
final newCovers = <String>[];
for (final book in books.take(9)) {
final imageUrl = maProvider.getImageUrl(book);
@@ -194,34 +233,15 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
newCovers.add(imageUrl);
}
}
setState(() {
_audiobooks = books;
_sortAudiobooks(); // Sort inside setState so UI updates
// Update covers if we got new ones (may have more than initial)
if (newCovers.isNotEmpty) {
_coverUrls = newCovers;
}
_isLoading = false;
});
// Debug: Log sorted order
_logger.log('📚 Sorted order ($_sortOrder):');
for (final book in _audiobooks) {
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
if (newCovers.isNotEmpty) {
_coverUrls = newCovers;
}
}
_isLoading = false;
});
// Extract colors from book covers asynchronously
_extractCoverColors(maProvider);
}
} catch (e, stack) {
_logger.log('📚 SeriesScreen error: $e');
_logger.log('📚 SeriesScreen stack: $stack');
if (mounted) {
setState(() {
_error = 'Failed to load books: $e';
_isLoading = false;
});
}
if (needsCovers && _extractedColors.isEmpty) {
_extractCoverColors(maProvider);
}
}
@@ -283,7 +303,9 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
// App bar with series cover collage (matches audiobook detail screen)
SliverAppBar(
@@ -301,25 +323,31 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
children: [
const SizedBox(height: 60),
// Series cover collage - responsive size
Hero(
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
// Shadow container outside Hero to avoid widget tree mismatch
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
// Hero child matches source: RepaintBoundary > AspectRatio > ClipRRect
child: Hero(
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: RepaintBoundary(
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _buildSeriesCover(colorScheme, maProvider),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _buildSeriesCover(colorScheme, maProvider),
),
),
),
),
@@ -457,7 +485,29 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
);
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
);
@@ -491,6 +541,9 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
);
}
// Return cached cover widget if already built (prevents rebuilds from unrelated setState)
if (_cachedCoverWidget != null) return _cachedCoverWidget!;
final covers = _coverUrls;
// Dynamic grid size based on number of covers (matches library screen)
@@ -514,7 +567,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
}
// Build grid using Column/Row for proper sizing (no scrolling issues)
return Column(
// Cache the widget so it never rebuilds from unrelated setState calls
_cachedCoverWidget = Column(
children: List.generate(gridSize, (row) {
return Expanded(
child: Row(
@@ -525,8 +579,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
return Expanded(
child: coverUrl != null
? CachedNetworkImage(
key: ValueKey(coverUrl),
imageUrl: coverUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
@@ -548,6 +605,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
);
}),
);
return _cachedCoverWidget!;
}
/// Builds an empty cell with either a solid color or a nested grid
@@ -665,6 +723,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
+171 -1
View File
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart';
import '../widgets/global_player_overlay.dart';
import 'new_home_screen.dart';
import 'new_library_screen.dart';
@@ -19,6 +22,7 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
final GlobalKey<SearchScreenState> _searchScreenKey = GlobalKey<SearchScreenState>();
DateTime? _lastBackPress;
final _cachedNavColor = _CachedNavColor();
@override
void initState() {
@@ -105,7 +109,15 @@ class _HomeScreenState extends State<HomeScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
// No bottomNavigationBar here - it's now in GlobalPlayerOverlay
// extendBody: true preserves the existing layout where content fills the
// full screen and screens manage their own bottom padding (BottomSpacing).
// Without this, Scaffold would constrain the body above the nav bar,
// causing double bottom spacing.
extendBody: true,
// BottomNavigationBar requires an Overlay ancestor (for Tooltip);
// Scaffold inside Navigator provides one. Previously it was in
// GlobalPlayerOverlay's Stack (outside Navigator) causing crashes.
bottomNavigationBar: _buildBottomNavigationBar(context, colorScheme),
body: Stack(
children: [
// Home and Library use IndexedStack for state preservation
@@ -131,4 +143,162 @@ class _HomeScreenState extends State<HomeScreen> {
},
);
}
/// Build the bottom navigation bar with adaptive colors and player expansion animation.
/// Moved here from GlobalPlayerOverlay so it has proper Overlay ancestry
/// (required by BottomNavigationBar's internal Tooltip widgets).
Widget? _buildBottomNavigationBar(BuildContext context, ColorScheme colorScheme) {
// Hide nav bar when not connected and no cached players
final showNavBar = context.select<MusicAssistantProvider, bool>(
(p) => p.isConnected || p.hasCachedPlayers,
);
if (!showNavBar) return null;
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
builder: (context, expansionState, _) {
// Nav bar color logic - only use adaptive colors when:
// 1. Player is expanding/expanded, OR
// 2. On a detail screen (isOnDetailScreen)
// On home screen with collapsed player: always use default theme colors
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
// Nav bar background color
final Color navBgColor;
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
// Player is expanding - blend from surface to player's adaptive color
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
} else if (useAdaptiveColors) {
// On a detail screen - use adaptive surface color for nav bar
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
navBgColor = adaptiveBg ?? colorScheme.surface;
} else {
// Home screen with collapsed player - use default surface color
navBgColor = colorScheme.surface;
}
// Icon color - only use adaptive colors when appropriate
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
? themeProvider.adaptiveColors!.primary
: colorScheme.primary;
// Blend icon color with player's primary color during expansion
Color sourceColor = baseSourceColor;
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Fade out and slide down nav bar as player expands
// Use IgnorePointer when faded to prevent accidental taps
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
return IgnorePointer(
ignoring: navOpacity < 0.1,
child: Transform.translate(
offset: Offset(0, navSlideDown),
child: Opacity(
opacity: navOpacity,
child: Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
// Clear adaptive colors when switching tabs (detail screen colors shouldn't persist)
themeProvider.clearAdaptiveColors();
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Symbols.book_2, fill: 0),
activeIcon: const Icon(Symbols.book_2, fill: 1),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
),
),
),
);
},
);
},
);
}
}
/// Cached color with contrast adjustment for nav bar icon colors.
/// Avoids expensive HSL conversions during scroll/animation.
class _CachedNavColor {
Color? _sourceColor;
bool? _isDark;
Color? _adjustedColor;
Color getAdjustedColor(Color sourceColor, bool isDark) {
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
return _adjustedColor!;
}
var navSelectedColor = sourceColor;
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
_sourceColor = sourceColor;
_isDark = isDark;
_adjustedColor = navSelectedColor;
return navSelectedColor;
}
}
+271 -62
View File
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/sync_service.dart';
@@ -16,7 +18,7 @@ import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart';
import '../widgets/mixes_row.dart';
import '../widgets/discovery_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -28,14 +30,13 @@ class NewHomeScreen extends StatefulWidget {
State<NewHomeScreen> createState() => _NewHomeScreenState();
}
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
static final _logger = DebugLogger();
Key _refreshKey = UniqueKey();
final ValueNotifier<int> _refreshSignal = ValueNotifier<int>(0);
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true;
bool _showExternalMixes = false;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
@@ -49,6 +50,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showDiscoverSeries = false;
// Row order (loaded from settings)
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Discovery folders (dynamic rows from provider recommendations)
List<RecommendationFolder> _discoveryFolders = [];
// Discovery row preferences (itemId -> enabled)
Map<String, bool> _discoveryRowEnabled = {};
@override
bool get wantKeepAlive => true;
@@ -58,25 +63,82 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo;
int _lastHomeRefreshCounter = 0; // Track home refresh counter to force row refreshes
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadSettings();
// Listen to sync completion to refresh favorite rows when data becomes available
SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState();
// Always listen to provider for home refresh counter changes
final provider = context.read<MusicAssistantProvider>();
_lastHomeRefreshCounter = provider.homeRefreshCounter;
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
// Reload settings only when switching back to home from settings tab
navigationProvider.addListener(_onNavigationChanged);
}
int _lastNavIndex = 0;
void _onNavigationChanged() {
final newIndex = navigationProvider.selectedIndex;
// Reload preferences when leaving settings tab (3) for home tab (0)
if (_lastNavIndex == 3 && newIndex == 0) {
_reloadPreferencesOnly();
}
_lastNavIndex = newIndex;
}
/// Reload only local preferences (no API calls). Used when switching back from settings.
Future<void> _reloadPreferencesOnly() async {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
}
@override
void dispose() {
navigationProvider.removeListener(_onNavigationChanged);
SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener if attached
// Clean up provider listener
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
WidgetsBinding.instance.removeObserver(this);
_refreshSignal.dispose();
super.dispose();
}
@@ -98,21 +160,31 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
});
}
/// Called when MusicAssistantProvider changes (for cache load detection)
/// Called when MusicAssistantProvider changes (for cache load detection and home refresh)
void _onProviderChanged() {
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
final currentCount = _providerListeningTo!.artists.length;
// If artists went from 0 to non-zero, refresh
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_providerListeningTo!.removeListener(_onProviderChanged);
_providerListeningTo = null;
setState(() {
_refreshKey = UniqueKey();
});
if (!mounted || _providerListeningTo == null) return;
final provider = _providerListeningTo!;
final currentRefreshCounter = provider.homeRefreshCounter;
// Check if home refresh counter changed (indicates authentication completed and cache was invalidated)
if (currentRefreshCounter != _lastHomeRefreshCounter) {
_logger.log('🔄 Home: refresh counter changed ($_lastHomeRefreshCounter -> $currentRefreshCounter), refreshing all rows');
_lastHomeRefreshCounter = currentRefreshCounter;
_refreshSignal.value++;
return; // Don't process other refresh conditions in this cycle
}
// Check for artists cache load (when favorites were empty on load)
if (_hadEmptyFavoritesOnLoad) {
final currentCount = provider.artists.length;
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_refreshSignal.value++;
}
_lastArtistCount = currentCount;
}
_lastArtistCount = currentCount;
}
/// Called when SyncService status changes
@@ -128,27 +200,17 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
if (mounted) {
setState(() {
_refreshKey = UniqueKey();
});
_refreshSignal.value++;
}
}
_lastSyncStatus = newStatus;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
if (state == AppLifecycleState.resumed) {
_loadSettings();
}
}
Future<void> _loadSettings() async {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showExternalMixes = await SettingsService.getShowExternalMixes();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
@@ -159,12 +221,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showExternalMixes = showExternalMixes;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
@@ -175,8 +237,63 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
// Load discovery folders dynamically (after settings load)
// Skip if already loaded to avoid unnecessary rebuilds on settings reload
if (_discoveryFolders.isEmpty) {
await _loadDiscoveryFolders(forceRefresh: false);
} else {
// Just re-sync the row order with current enabled state (no API call)
setState(() {
_syncDiscoveryRowOrder(_discoveryFolders);
});
}
}
Future<void> _loadDiscoveryFolders({bool forceRefresh = false}) async {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
try {
var folders = await provider.getDiscoveryFoldersWithCache();
// If cached result is empty, force a refresh to get from API/database
if (folders.isEmpty && !forceRefresh) {
_logger.log('🔄 Discovery: Cached folders empty, forcing refresh...');
folders = await provider.getDiscoveryFoldersWithCache(forceRefresh: true);
}
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_syncDiscoveryRowOrder(folders);
});
}
} catch (e) {
_logger.log('⚠️ Failed to load discovery folders: $e');
}
}
/// Sync discovery row order without calling setState. Only adds new folders, never removes.
/// Visibility is controlled by _discoveryRowEnabled / _isRowEnabled().
bool _syncDiscoveryRowOrder(List<RecommendationFolder> folders) {
bool didModify = false;
for (final folder in folders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
_homeRowOrder.add(discoveryRowId);
didModify = true;
}
}
if (didModify) {
SettingsService.setHomeRowOrder(_homeRowOrder);
}
return didModify;
}
Future<void> _onRefresh() async {
@@ -191,9 +308,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
await _loadSettings();
if (mounted) {
setState(() {
_refreshKey = UniqueKey();
});
_refreshSignal.value++;
}
}
@@ -315,6 +430,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
),
),
),
// Bottom fade gradient to mask content behind mini player
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.miniPlayerHeight + 22.0,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background.withOpacity(0.25),
colorScheme.background.withOpacity(0.85),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
),
],
);
},
@@ -335,11 +473,16 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
/// Check if a specific row is enabled
bool _isRowEnabled(String rowId) {
// Discovery folders use the pattern 'discovery:{itemId}'
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
// Default to true if not explicitly set
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) {
case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists;
case 'discover-albums': return _showDiscoverAlbums;
case 'external-mixes': return _showExternalMixes;
case 'continue-listening': return _showContinueListeningAudiobooks;
case 'discover-audiobooks': return _showDiscoverAudiobooks;
case 'discover-series': return _showDiscoverSeries;
@@ -360,19 +503,18 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
builder: (context, constraints) {
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
final maxHeight = constraints.maxHeight.isFinite
? constraints.maxHeight
: MediaQuery.of(context).size.height;
final availableHeight = (maxHeight - BottomSpacing.withMiniPlayer).clamp(0.0, double.infinity);
// With extendBody: true, constraints include the area behind the nav bar.
// Subtract mini player overlay space (mini player + its margins sit above the nav bar).
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0; // 84 + 22 = 106
final availableHeight = constraints.maxHeight - miniPlayerSpace;
// Account for margins between rows (2px each)
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
// Account for margins between rows (2px each, 2 margins for 3 rows).
// Always use the 3-row margin calculation so rowHeight stays stable
// regardless of how many rows are currently enabled (avoids jitter on reload).
const marginSize = 2.0;
final enabledRows = _countEnabledRows();
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
final rawRowHeight = (availableHeight - marginsInView) / 3;
final rowHeight = rawRowHeight.isFinite && rawRowHeight > 0 ? rawRowHeight : 237.0;
const marginsInView = 2 * marginSize; // always 2 margins for 3-row layout
final rowHeight = (availableHeight - marginsInView) / 3 + 2;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
@@ -394,11 +536,18 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
behavior: const _StretchScrollBehavior(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight),
// For ≤3 rows: no padding needed (rows fit within viewport)
// For 4+ rows: pad so last row scrolls above mini player
padding: enabledRows >= 4
? EdgeInsets.only(bottom: miniPlayerSpace)
: EdgeInsets.zero,
child: SizedBox(
// Ensure minimum height for pull-to-refresh when empty
height: enabledRows == 0 ? availableHeight : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight, enabledRows == 0),
),
),
),
),
@@ -408,9 +557,51 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
/// Build rows in the user's configured order
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight, bool isEmpty) {
final rows = <Widget>[];
// Show empty state with refresh hint when no rows are enabled
if (isEmpty) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
rows.add(
SizedBox.expand(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_rounded,
size: 64,
color: colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
S.of(context)!.noRowsEnabled,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
S.of(context)!.pullToRefreshHint,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.4),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
);
return rows;
}
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
@@ -435,6 +626,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedRecentAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'discover-artists':
@@ -444,6 +636,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(),
getCachedArtists: () => provider.getCachedDiscoverArtists(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'discover-albums':
@@ -453,16 +646,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
rowHeight: rowHeight,
);
case 'external-mixes':
if (!_showExternalMixes) return null;
return MixesRow(
key: const ValueKey('external-mixes'),
title: S.of(context)!.playlistMixes,
loadMixes: () => provider.getExternalMixesWithCache(),
getCachedMixes: () => provider.getCachedExternalMixes(),
heroTagSuffix: 'home',
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'continue-listening':
@@ -498,6 +682,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('favorite-albums'),
title: S.of(context)!.favoriteAlbums,
loadAlbums: () => provider.getFavoriteAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'favorite-artists':
@@ -506,6 +691,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('favorite-artists'),
title: S.of(context)!.favoriteArtists,
loadArtists: () => provider.getFavoriteArtists(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'favorite-tracks':
@@ -544,6 +730,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
rowHeight: rowHeight,
);
default:
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final folderId = rowId.substring('discovery:'.length);
// Check per-row preference
final isEnabled = _discoveryRowEnabled[folderId] ?? false;
if (!isEnabled) return null;
try {
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == folderId,
);
return DiscoveryRow(
key: ValueKey(rowId),
title: folder.name,
loadItems: () => provider.getDiscoveryFolderItems(folderId),
getCachedItems: () => provider.getCachedDiscoveryFolderItems(folderId),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
} catch (e) {
// Folder not found, skip this row
}
}
return null;
}
}
+45 -6
View File
@@ -1506,9 +1506,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: _tabCount,
// Use default physics - custom physics may cause issues with Flutter 3.38
physics: const PageScrollPhysics(),
itemBuilder: (context, index) => Builder(
builder: (context) => _buildTabAtIndex(context, l10n, index),
),
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
),
),
),
@@ -3412,7 +3410,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ PODCASTS TAB ============
// Consumer wrapper avoids using context.select inside PageView.builder's
// sliver context, which would rebuild the entire list instead of individual items.
Widget _buildPodcastsTab(BuildContext context, S l10n) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
return _buildPodcastsTabContent(context, l10n, maProvider);
},
);
}
Widget _buildPodcastsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// PERF: Use select() to only rebuild when podcasts, loading state, or enabled providers changes
@@ -3716,7 +3724,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ RADIO TAB ============
// Consumer wrapper avoids using context.select inside PageView.builder's
// sliver context, which would rebuild the entire list instead of individual items.
Widget _buildRadioStationsTab(BuildContext context, S l10n) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
return _buildRadioTabContent(context, l10n, maProvider);
},
);
}
Widget _buildRadioTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// PERF: Use select() to only rebuild when radio stations, loading state, or enabled providers changes
@@ -3966,7 +3984,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ ARTISTS TAB ============
// Consumer wrapper avoids using context.select inside PageView.builder's
// sliver context, which would rebuild the entire list instead of individual items.
Widget _buildArtistsTab(BuildContext context, S l10n) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
return _buildArtistsTabContent(context, l10n, maProvider);
},
);
}
Widget _buildArtistsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
// Use select to only rebuild when specific fields change
// SyncService changes are handled by _onSyncServiceChanged listener
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
@@ -4203,7 +4231,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ ALBUMS TAB ============
// Consumer wrapper avoids using context.select inside PageView.builder's
// sliver context, which would rebuild the entire list instead of individual items.
Widget _buildAlbumsTab(BuildContext context, S l10n) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
return _buildAlbumsTabContent(context, l10n, maProvider);
},
);
}
Widget _buildAlbumsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
// Use select to only rebuild when specific fields change
// SyncService changes are handled by _onSyncServiceChanged listener
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
@@ -6041,8 +6079,9 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
// Position menu below and to the left of the button
final menuWidth = 220.0;
final screenWidth = MediaQuery.of(context).size.width;
final left = (widget.position.dx + widget.buttonSize.width - menuWidth).clamp(8.0, screenWidth - menuWidth - 8);
final screenSize = MediaQuery.of(context).size;
final bottomInset = BottomSpacing.withMiniPlayer + MediaQuery.of(context).viewPadding.bottom;
final left = (widget.position.dx + widget.buttonSize.width - menuWidth).clamp(8.0, screenSize.width - menuWidth - 8);
final top = widget.position.dy + widget.buttonSize.height + 4;
return Stack(
@@ -6072,7 +6111,7 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
child: Container(
width: menuWidth,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height - top - 50,
maxHeight: screenSize.height - top - bottomInset,
),
child: SingleChildScrollView(
child: Column(
+58 -22
View File
@@ -59,7 +59,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
super.initState();
_isFavorite = widget.playlist.favorite ?? false;
_isInLibrary = widget.playlist.inLibrary;
_loadTracks();
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -68,6 +67,15 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer track loading until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadTracks();
}
});
});
}
Future<void> _extractColors() async {
@@ -797,7 +805,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -882,7 +892,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1052,12 +1062,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
);
},
child: ListTile(
contentPadding: const EdgeInsets.only(left: 12, right: 20),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Track number
SizedBox(
width: 28,
width: 24,
child: Text(
'${index + 1}',
style: textTheme.bodyMedium?.copyWith(
@@ -1066,7 +1077,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
const SizedBox(width: 16),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
@@ -1115,25 +1126,28 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
trailing: Padding(
padding: const EdgeInsets.only(right: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
textAlign: TextAlign.right,
),
),
],
],
),
),
onTap: () => _playTrack(index),
),
@@ -1144,6 +1158,28 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
+87 -47
View File
@@ -61,7 +61,15 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
_loadEpisodes();
});
// Defer episode loading until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadEpisodes();
}
});
});
}
@@ -380,54 +388,62 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
Future<void> _loadEpisodes() async {
if (_isLoadingEpisodes) return;
setState(() {
_isLoadingEpisodes = true;
});
final maProvider = context.read<MusicAssistantProvider>();
// 1. Show cached episodes immediately (if available)
final cached = maProvider.getCachedPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
if (cached != null && cached.isNotEmpty && mounted) {
_logger.log('🎙️ Showing ${cached.length} cached episodes for ${widget.podcast.name}');
setState(() {
_episodes = cached;
_sortEpisodes();
_isLoadingEpisodes = false;
});
} else {
setState(() {
_isLoadingEpisodes = true;
});
}
// 2. Fetch fresh data in background
try {
final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
List<MediaItem> episodes = [];
List<MediaItem> episodes = [];
// Try loading with the podcast's own ID and provider first
try {
episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId,
provider: widget.podcast.provider,
);
} catch (e) {
_logger.log('🎙️ Primary episode load failed: $e');
}
// Try loading with the podcast's own ID and provider first
episodes = await maProvider.getPodcastEpisodesWithCache(
widget.podcast.itemId,
provider: widget.podcast.provider,
forceRefresh: cached != null && cached.isNotEmpty,
);
// If that failed and we have provider mappings, try those
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
for (final mapping in widget.podcast.providerMappings!) {
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
try {
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
episodes = await maProvider.api!.getPodcastEpisodes(
mapping.itemId,
provider: mapping.providerInstance,
);
if (episodes.isNotEmpty) {
_logger.log('🎙️ Loaded episodes via provider mapping');
break;
}
} catch (e) {
_logger.log('🎙️ Provider mapping failed: $e');
// If that failed and we have provider mappings, try those
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
for (final mapping in widget.podcast.providerMappings!) {
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
try {
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
episodes = await maProvider.getPodcastEpisodesWithCache(
mapping.itemId,
provider: mapping.providerInstance,
);
if (episodes.isNotEmpty) {
_logger.log('🎙️ Loaded episodes via provider mapping');
break;
}
} catch (e) {
_logger.log('🎙️ Provider mapping failed: $e');
}
}
}
}
if (mounted) {
if (mounted && episodes.isNotEmpty) {
final changed = _episodes.length != episodes.length;
if (changed || _episodes.isEmpty) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
// Debug: Log first episode metadata to see available fields
if (episodes.isNotEmpty) {
final first = episodes.first;
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
_logger.log('🎙️ First episode metadata: ${first.metadata}');
}
setState(() {
_episodes = episodes;
_sortEpisodes();
@@ -436,12 +452,12 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
} catch (e) {
_logger.log('🎙️ Error loading episodes: $e');
} finally {
if (mounted) {
setState(() {
_isLoadingEpisodes = false;
});
}
}
if (mounted && _isLoadingEpisodes) {
setState(() {
_isLoadingEpisodes = false;
});
}
}
@@ -654,7 +670,9 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -736,7 +754,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -1109,6 +1127,28 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
+26 -2
View File
@@ -255,7 +255,9 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
final coverSize = (constraints.maxWidth * 0.6).clamp(180.0, 280.0);
final expandedHeight = coverSize + 70;
return CustomScrollView(
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -330,7 +332,7 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
// Header content with title, podcast name, metadata, and actions
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -495,6 +497,28 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
+86 -37
View File
@@ -108,6 +108,9 @@ class SearchScreenState extends State<SearchScreen> {
};
bool _isSearching = false;
bool _hasSearched = false;
// Race condition fix: Track the active search request ID
int _currentSearchId = 0;
bool _hasPendingSearch = false;
// PERF: Use ValueNotifier to avoid full screen rebuild on filter change
final ValueNotifier<String> _activeFilterNotifier = ValueNotifier('all');
String? _searchError;
@@ -311,6 +314,10 @@ class SearchScreenState extends State<SearchScreen> {
void requestFocus() {
if (mounted) {
setState(() {
_isSearchBarVisible = true;
_lastVerticalScrollOffset = 0;
});
_focusNode.requestFocus();
}
}
@@ -359,6 +366,8 @@ class SearchScreenState extends State<SearchScreen> {
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(Timings.searchDebounce, () {
// Race condition fix: Check mounted before proceeding
if (!mounted) return;
_performSearch(query, keepFocus: true);
});
}
@@ -375,6 +384,10 @@ class SearchScreenState extends State<SearchScreen> {
return;
}
// Race condition fix: Generate unique ID for this search
final searchId = ++_currentSearchId;
_hasPendingSearch = true;
setState(() {
_isSearching = true;
_searchError = null;
@@ -400,6 +413,11 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
_logger.log('Global radio search failed: $e');
}
// Race condition fix: Abort if a newer search has started
if (!mounted || searchId != _currentSearchId) {
_logger.log('⏭️ Ignoring stale radio search results for "$query"');
return;
}
}
// 3. Combine and deduplicate by name
@@ -428,6 +446,11 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
_logger.log('Global podcast search failed: $e');
}
// Race condition fix: Abort if a newer search has started
if (!mounted || searchId != _currentSearchId) {
_logger.log('⏭️ Ignoring stale podcast search results for "$query"');
return;
}
}
// 3. Combine and deduplicate by name
@@ -442,46 +465,55 @@ class SearchScreenState extends State<SearchScreen> {
}
}
if (mounted) {
// Combine all results
var combinedResults = {
...results,
'radios': allRadios.values.toList(),
'podcasts': allPodcasts.values.toList(),
};
// Race condition fix: Only update results if this is still the latest search
if (!mounted) return;
if (searchId != _currentSearchId) {
_logger.log('⏭️ Ignoring stale search results for "$query"');
return;
}
// When "in library" mode is on, also filter by enabled providers
if (_libraryOnly) {
final enabledProviders = provider.enabledProviderIds.toSet();
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}
// Combine all results
var combinedResults = {
...results,
'radios': allRadios.values.toList(),
'podcasts': allPodcasts.values.toList(),
};
setState(() {
_searchResults = combinedResults;
_isSearching = false;
_hasSearched = true;
_cachedListItems.clear(); // PERF: Clear cache on new results
_cachedAvailableFilters = null;
});
// When "in library" mode is on, also filter by enabled providers
if (_libraryOnly) {
final enabledProviders = provider.enabledProviderIds.toSet();
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}
// Save to search history if we got results
final hasResults = results.values.any((list) => list.isNotEmpty);
if (hasResults && DatabaseService.instance.isInitialized) {
DatabaseService.instance.saveSearchQuery(query);
_loadRecentSearches(); // Refresh the list
}
setState(() {
_searchResults = combinedResults;
_isSearching = false;
_hasSearched = true;
_hasPendingSearch = false;
_cachedListItems.clear(); // PERF: Clear cache on new results
_cachedAvailableFilters = null;
});
// Keep keyboard open if user is still typing
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
// Save to search history if we got results
final hasResults = results.values.any((list) => list.isNotEmpty);
if (hasResults && DatabaseService.instance.isInitialized) {
DatabaseService.instance.saveSearchQuery(query);
_loadRecentSearches(); // Refresh the list
}
// Keep keyboard open if user is still typing
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
} catch (e) {
_logger.log('Search error: $e');
if (mounted) {
if (!mounted) return;
// Race condition fix: Only update error state if this is still the latest search
if (searchId == _currentSearchId) {
setState(() {
_isSearching = false;
_hasSearched = true;
_hasPendingSearch = false;
_searchError = S.of(context)!.searchFailed;
});
if (keepFocus && _focusNode.hasFocus) {
@@ -570,8 +602,8 @@ class SearchScreenState extends State<SearchScreen> {
),
const SizedBox(width: 8),
// Library-only toggle with circle background
Builder(
builder: (context) {
LayoutBuilder(
builder: (context, constraints) {
final control = SizedBox(
width: 44,
height: 44,
@@ -600,7 +632,7 @@ class SearchScreenState extends State<SearchScreen> {
),
);
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
// Only use Tooltip if an Overlay ancestor exists
final hasOverlay = Overlay.maybeOf(context) != null;
if (!hasOverlay) {
return control;
@@ -789,6 +821,7 @@ class SearchScreenState extends State<SearchScreen> {
child: ListView.builder(
// PERF: Use key to preserve scroll position per filter
key: PageStorageKey('list_$filterForPage'),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(16, 16, 16, BottomSpacing.navBarOnly),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
@@ -1168,6 +1201,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1210,6 +1244,7 @@ class SearchScreenState extends State<SearchScreen> {
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
onTap: () {
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1238,6 +1273,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1316,6 +1352,7 @@ class SearchScreenState extends State<SearchScreen> {
),
),
onTap: () {
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1344,6 +1381,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1412,7 +1450,10 @@ class SearchScreenState extends State<SearchScreen> {
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
)
: null,
onTap: () => _playTrack(track),
onTap: () {
removeFocus();
_playTrack(track);
},
),
),
);
@@ -1430,6 +1471,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1518,6 +1560,7 @@ class SearchScreenState extends State<SearchScreen> {
)
: null,
onTap: () {
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1548,6 +1591,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1623,7 +1667,7 @@ class SearchScreenState extends State<SearchScreen> {
)
: null,
onTap: () {
// Update adaptive colors before navigation
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1650,6 +1694,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1698,7 +1743,10 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => _playRadioStation(radio),
onTap: () {
removeFocus();
_playRadioStation(radio);
},
),
),
);
@@ -1716,6 +1764,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1772,7 +1821,7 @@ class SearchScreenState extends State<SearchScreen> {
overflow: TextOverflow.ellipsis,
),
onTap: () {
// Update adaptive colors before navigation
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
+565 -19
View File
@@ -1,6 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/locale_provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart';
@@ -8,6 +12,7 @@ import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../theme/app_theme.dart';
import '../utils/page_transitions.dart';
import '../providers/navigation_provider.dart';
import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart';
import 'login_screen.dart';
@@ -20,13 +25,14 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
final _scrollController = ScrollController();
final _lastFmApiKeyController = TextEditingController();
final _audioDbApiKeyController = TextEditingController();
String _appVersion = '';
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true;
bool _showExternalMixes = false;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
@@ -47,11 +53,71 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showHints = true;
// Display settings
bool _showProviderIcons = true;
// Discovery folders state
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {};
bool _isLoadingDiscoveryFolders = false;
/// All available static home screen rows (that can be toggled on/off)
static const List<String> _allAvailableRows = [
'recent-albums',
'discover-artists',
'discover-albums',
'discovery-mixes',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
/// Filtered row order for the draggable list
/// Shows all available rows (in home order + discovery rows), regardless of enabled state
List<String> get _filteredRowOrder {
final rows = <String>[];
// First, add all static rows in their current home order
for (final rowId in _homeRowOrder) {
if (rowId == 'discovery-mixes') continue;
rows.add(rowId);
}
// Then, add any static rows that aren't in home order yet (so users can enable them)
for (final rowId in _allAvailableRows) {
if (!_homeRowOrder.contains(rowId) && rowId != 'discovery-mixes') {
rows.add(rowId);
}
}
// Finally, add any discovery rows from provider that aren't in home order yet
for (final folder in _discoveryFolders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
rows.add(discoveryRowId);
}
}
return rows;
}
@override
void initState() {
super.initState();
_loadSettings();
_loadAppVersion();
}
Future<void> _loadAppVersion() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = '${info.version}+${info.buildNumber}';
});
}
}
Future<void> _loadSettings() async {
@@ -69,7 +135,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showExternalMixes = await SettingsService.getShowExternalMixes();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
@@ -95,12 +160,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load display settings
final showProviderIcons = await SettingsService.getShowProviderIcons();
// Load discovery row preferences
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showExternalMixes = showExternalMixes;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
@@ -115,20 +182,100 @@ class _SettingsScreenState extends State<SettingsScreen> {
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
// Load discovery folders after initial state is set
_loadDiscoveryFolders();
}
@override
void dispose() {
_scrollController.dispose();
_lastFmApiKeyController.dispose();
_audioDbApiKeyController.dispose();
super.dispose();
}
/// Load discovery folders from Music Assistant
Future<void> _loadDiscoveryFolders() async {
if (_discoveryFolders.isEmpty && mounted) {
setState(() {
_isLoadingDiscoveryFolders = true;
});
}
final provider = context.read<MusicAssistantProvider>();
try {
final folders = await provider.getDiscoveryFoldersWithCache();
// Also reload discovery row preferences to ensure they're up to date
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Refresh discovery folders from Music Assistant
Future<void> _refreshDiscoveryFolders() async {
final provider = context.read<MusicAssistantProvider>();
try {
setState(() {
_isLoadingDiscoveryFolders = true;
});
await provider.refreshDiscoveryFolders();
final folders = provider.getCachedDiscoveryFolders() ?? [];
if (mounted) {
setState(() {
_discoveryFolders = folders;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Toggle a specific discovery row on/off
Future<void> _toggleDiscoveryRow(String itemId, bool enabled) async {
setState(() {
_discoveryRowEnabled[itemId] = enabled;
});
await SettingsService.setDiscoveryRowPreference(itemId, enabled);
}
// Helper to get row display info
Map<String, String> _getRowInfo(String rowId) {
final s = S.of(context)!;
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == itemId,
orElse: () => RecommendationFolder(
itemId: itemId,
provider: 'unknown',
name: rowId,
items: [],
),
);
return {'title': folder.name, 'subtitle': 'Discovery row from Music Assistant'};
}
switch (rowId) {
case 'recent-albums':
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
@@ -136,8 +283,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums':
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'external-mixes':
return {'title': s.playlistMixes, 'subtitle': s.showPlaylistMixes};
case 'discovery-mixes':
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription};
case 'continue-listening':
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks':
@@ -163,6 +310,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to get row enabled state
bool _getRowEnabled(String rowId) {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) {
case 'recent-albums':
return _showRecentAlbums;
@@ -170,8 +322,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showDiscoverArtists;
case 'discover-albums':
return _showDiscoverAlbums;
case 'external-mixes':
return _showExternalMixes;
case 'continue-listening':
return _showContinueListeningAudiobooks;
case 'discover-audiobooks':
@@ -198,6 +348,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to set row enabled state
void _setRowEnabled(String rowId, bool value) {
setState(() {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
_discoveryRowEnabled[itemId] = value;
SettingsService.setDiscoveryRowPreference(itemId, value);
// Always add to home row order for position tracking
if (!_homeRowOrder.contains(rowId)) {
_homeRowOrder.add(rowId);
SettingsService.setHomeRowOrder(_homeRowOrder);
}
return;
}
// Save the setting
switch (rowId) {
case 'recent-albums':
_showRecentAlbums = value;
@@ -211,10 +375,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAlbums = value;
SettingsService.setShowDiscoverAlbums(value);
break;
case 'external-mixes':
_showExternalMixes = value;
SettingsService.setShowExternalMixes(value);
break;
case 'continue-listening':
_showContinueListeningAudiobooks = value;
SettingsService.setShowContinueListeningAudiobooks(value);
@@ -251,6 +411,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoritePodcasts = value;
SettingsService.setShowFavoritePodcasts(value);
break;
default:
break;
}
// When enabling a row, add it to home row order if not already present
if (value && !_homeRowOrder.contains(rowId)) {
_homeRowOrder.add(rowId);
SettingsService.setHomeRowOrder(_homeRowOrder);
}
});
}
@@ -285,7 +453,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
onPressed: () {
// Settings is rendered conditionally in HomeScreen (not pushed as a route),
// so Navigator.pop would pop the entire HomeScreen causing a black screen.
// Instead, switch tab back to Home and restore the mini player.
GlobalPlayerOverlay.showPlayer();
navigationProvider.setSelectedIndex(0);
},
color: colorScheme.onBackground,
),
title: Text(
@@ -311,6 +485,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
body: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -856,7 +1031,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 16),
// Reorderable home rows list
// Reorderable home rows list (exclude discovery-mixes from list)
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -867,24 +1042,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _homeRowOrder.length,
itemCount: _filteredRowOrder.length,
onReorder: (oldIndex, newIndex) {
// Create a mutable copy of the filtered list for reordering
final displayRows = List<String>.from(_filteredRowOrder);
if (newIndex > oldIndex) newIndex--;
final item = displayRows.removeAt(oldIndex);
displayRows.insert(newIndex, item);
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _homeRowOrder.removeAt(oldIndex);
_homeRowOrder.insert(newIndex, item);
// Update _homeRowOrder with the new order (all rows, including discovery)
_homeRowOrder = displayRows;
});
SettingsService.setHomeRowOrder(_homeRowOrder);
},
itemBuilder: (context, index) {
final rowId = _homeRowOrder[index];
final rowId = _filteredRowOrder[index];
final rowInfo = _getRowInfo(rowId);
final isEnabled = _getRowEnabled(rowId);
return Container(
key: ValueKey(rowId),
decoration: BoxDecoration(
border: index < _homeRowOrder.length - 1
border: index < _filteredRowOrder.length - 1
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
: null,
),
@@ -1015,6 +1195,149 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// About & Support section
Text(
S.of(context)!.aboutAndSupport,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Support Development banner
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFFEA4AAA).withOpacity(0.15),
Color(0xFF1F6FEB).withOpacity(0.15),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.favorite_rounded,
color: Color(0xFFEA4AAA),
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
S.of(context)!.supportDevelopment,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(
S.of(context)!.ensembleIsUnofficial,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(height: 16),
Row(
children: [
// GitHub Sponsors button
Expanded(
child: _DonationButton(
label: S.of(context)!.sponsorOnGitHub,
color: Color(0xFF1F6FEB),
iconWidget: Icon(MdiIcons.github, size: 20),
onTap: () => _launchUrl('https://github.com/sponsors/CollotsSpot'),
),
),
const SizedBox(width: 12),
// Ko-fi button
Expanded(
child: _DonationButton(
icon: null,
label: S.of(context)!.buyMeAKoFi,
color: Color(0xFFFF5E5B),
iconWidget: Icon(Icons.coffee_rounded, color: Colors.white),
onTap: () => _launchUrl('https://ko-fi.com/collotsspot'),
),
),
],
),
],
),
),
const SizedBox(height: 16),
// About Ensemble card
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.aboutEnsemble,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
S.of(context)!.version,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(width: 4),
Text(
_appVersion,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
_LinkButton(
icon: Icons.code_rounded,
label: S.of(context)!.visitGitHubRepo,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble'),
),
const SizedBox(height: 8),
_LinkButton(
icon: Icons.bug_report_outlined,
label: S.of(context)!.reportABug,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble/issues'),
),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
@@ -1092,4 +1415,227 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
/// Build the discovery section with individual row toggles
Widget _buildDiscoverySection(ColorScheme colorScheme, TextTheme textTheme) {
final s = S.of(context)!;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and refresh button
Row(
children: [
Icon(
Icons.explore_outlined,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
s.discoveryRows,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
if (_isLoadingDiscoveryFolders)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary.withOpacity(0.7),
),
),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _refreshDiscoveryFolders,
tooltip: s.refreshDiscoveryRows,
color: colorScheme.onSurfaceVariant,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
),
const SizedBox(height: 12),
// Discovery rows list
if (_discoveryFolders.isEmpty && !_isLoadingDiscoveryFolders)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
s.noDiscoveryRows,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
)
else
..._discoveryFolders.map((folder) {
final itemId = folder.itemId;
final isEnabled = _discoveryRowEnabled[itemId] ?? false;
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: SwitchListTile(
title: Text(
folder.name,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
),
),
subtitle: folder.provider.isNotEmpty
? Text(
folder.provider,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 11,
),
)
: null,
value: isEnabled,
onChanged: (value) => _toggleDiscoveryRow(itemId, value),
activeColor: colorScheme.primary,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
dense: true,
),
);
}).toList(),
],
),
);
}
}
// Custom donation button widget
class _DonationButton extends StatelessWidget {
final String? icon;
final String label;
final Color color;
final VoidCallback onTap;
final Widget? iconWidget;
const _DonationButton({
this.icon,
required this.label,
required this.color,
required this.onTap,
this.iconWidget,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (iconWidget != null)
iconWidget!
else if (icon != null)
Image.asset(
icon!,
width: 20,
height: 20,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
);
}
}
// Custom link button widget
class _LinkButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _LinkButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Icon(
icon,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.primary,
),
),
const SizedBox(width: 4),
Icon(
Icons.open_in_new,
size: 14,
color: colorScheme.primary.withOpacity(0.7),
),
],
),
),
),
);
}
}
+770 -1
View File
@@ -2,12 +2,17 @@ import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import 'package:rxdart/rxdart.dart';
import '../debug_logger.dart';
import '../auth/auth_manager.dart';
import '../settings_service.dart';
import '../sync_service.dart';
import '../../providers/music_assistant_provider.dart';
import '../../models/media_item.dart' as ma;
/// Custom AudioHandler for Ensemble that provides full control over
/// notification actions and metadata updates.
class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer();
final AuthManager authManager;
final _logger = DebugLogger();
@@ -32,6 +37,16 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
// Track whether we're in remote control mode (controlling MA player, not playing locally)
bool _isRemoteMode = false;
// Android Auto: provider reference (set after app initialises)
MusicAssistantProvider? _autoProvider;
// Android Auto: track queue cache — maps context key to ordered track list.
// Populated during getChildren so playFromMediaId can queue the full album/playlist.
final Map<String, List<ma.Track>> _autoTrackCache = {};
// Android Auto: subjects for subscribeToChildren
final Map<String, BehaviorSubject<Map<String, dynamic>>> _autoChildrenSubjects = {};
// Custom control for switching players (uses stop action with custom icon)
static const _switchPlayerControl = MediaControl(
androidIcon: 'drawable/ic_switch_player',
@@ -312,6 +327,760 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
MediaItem? get currentMediaItem => _currentMediaItem;
// ---------------------------------------------------------------------------
// Android Auto — media browsing and playback
// ---------------------------------------------------------------------------
/// Wire the provider in after the app has initialised.
/// Called from _MusicAssistantAppState.initState().
void setProvider(MusicAssistantProvider provider) {
_autoProvider = provider;
_logger.log('AndroidAuto: provider set');
}
// Media ID constants — root categories
static const _autoIdHome = 'cat|home';
static const _autoIdMusic = 'cat|music';
static const _autoIdAudiobooks = 'cat|audiobooks';
static const _autoIdPodcasts = 'cat|podcasts';
static const _autoIdRadio = 'cat|radio';
// Media ID constants — Music subcategories
static const _autoIdPlaylists = 'cat|playlists';
static const _autoIdArtists = 'cat|artists';
static const _autoIdAlbums = 'cat|albums';
static const _autoIdFavorites = 'cat|favorites';
// Media ID constants — Audiobook subcategories
static const _autoIdAbAuthors = 'cat|ab_authors';
static const _autoIdAbBooks = 'cat|ab_books';
static const _autoIdAbSeries = 'cat|ab_series';
// Android Auto content style hints
static const _gridHints = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 2,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
};
// Icon URIs for category items
static const _iconPkg = 'com.collotsspot.ensemble';
static final _iconHome = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_home');
static final _iconMusic = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_music');
static final _iconBook = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_book');
static final _iconPodcast = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_podcast');
static final _iconRadio = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_radio');
static final _iconArtist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_artist');
static final _iconAlbum = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_album');
static final _iconPlaylist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_playlist');
static final _iconFavorite = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_favorite');
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
final provider = _autoProvider;
if (provider == null) {
_logger.log('AndroidAuto: getChildren("$parentMediaId") — provider not set yet');
return [];
}
// Ensure provider is connected — AA may browse before app is fully ready
if (!provider.isConnected) {
_logger.log('AndroidAuto: provider disconnected, starting reconnect');
// Don't await — reconnection can take seconds and AA may timeout
// getChildren. Cached data is served immediately; live API calls
// inside builders will await their own reconnect if needed.
provider.checkAndReconnect();
}
try {
final result = await _autoGetChildren(provider, parentMediaId);
_logger.log('AndroidAuto: getChildren "$parentMediaId" → ${result.length} items');
return result;
} catch (e, st) {
_logger.log('AndroidAuto: getChildren error for "$parentMediaId": $e\n$st');
return [];
}
}
Future<List<MediaItem>> _autoGetChildren(
MusicAssistantProvider provider, String parentMediaId) async {
switch (parentMediaId) {
// Root
case AudioService.browsableRootId:
return _autoBuildRoot();
// Home — subcategory folders matching user's homescreen settings
case _autoIdHome:
return _autoBuildHome();
// Music
case _autoIdMusic:
return _autoBuildMusicCategories();
case _autoIdPlaylists:
return _autoBuildPlaylistList(provider);
case _autoIdArtists:
return _autoBuildArtistList(provider);
case _autoIdAlbums:
return _autoBuildAlbumList(provider);
case _autoIdFavorites:
return _autoBuildFavorites(provider);
// Audiobooks
case _autoIdAudiobooks:
return _autoBuildAudiobookCategories();
case _autoIdAbAuthors:
return _autoBuildAudiobookAuthorList(provider);
case _autoIdAbBooks:
return _autoBuildAudiobookList(provider);
case _autoIdAbSeries:
return _autoBuildAudiobookSeriesList(provider);
// Podcasts
case _autoIdPodcasts:
return _autoBuildPodcastList(provider);
// Radio
case _autoIdRadio:
return _autoBuildRadioList(provider);
default:
// Home row content (home|recent-albums, etc.)
if (parentMediaId.startsWith('home|')) {
return _autoBuildHomeRowContent(provider, parentMediaId.substring(5));
}
return _autoBuildDynamicChildren(provider, parentMediaId);
}
}
/// Route prefix-based dynamic media IDs (albums, playlists, artists, etc.)
Future<List<MediaItem>> _autoBuildDynamicChildren(
MusicAssistantProvider provider, String parentMediaId) async {
final parts = parentMediaId.split('|');
if (parts.length < 2) return [];
switch (parts[0]) {
case 'playlist':
if (parts.length >= 3) {
return _autoBuildPlaylistTracks(provider, parts[1], parts[2]);
}
case 'album':
if (parts.length >= 3) {
return _autoBuildAlbumTracks(provider, parts[1], parts[2]);
}
case 'artist':
final name = parts.sublist(1).join('|');
return _autoBuildArtistAlbums(provider, name);
case 'podcast':
if (parts.length >= 3) {
return _autoBuildPodcastEpisodes(provider, parts[1], parts[2]);
}
case 'ab_author':
final authorName = parts.sublist(1).join('|');
return _autoBuildAuthorAudiobooks(provider, authorName);
case 'ab_series':
final seriesPath = parts.sublist(1).join('|');
return _autoBuildSeriesAudiobooks(provider, seriesPath);
}
_logger.log('AndroidAuto: unhandled parentMediaId "$parentMediaId"');
return [];
}
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
_autoChildrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
return _autoChildrenSubjects[parentMediaId]!.stream;
}
@override
Future<void> playFromMediaId(String mediaId,
[Map<String, dynamic>? extras]) async {
final provider = _autoProvider;
if (provider == null) {
_logger.log('AndroidAuto: playFromMediaId called before provider is set');
return;
}
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) {
_logger.log('AndroidAuto: no builtin player ID — cannot play');
return;
}
// Auto-switch to the builtin (local) player when AA triggers playback
if (provider.selectedPlayer?.playerId != playerId) {
final builtinPlayer = provider.availablePlayersUnfiltered
.where((p) => p.playerId == playerId)
.firstOrNull;
if (builtinPlayer != null) {
_logger.log('AndroidAuto: switching to builtin player "${builtinPlayer.name}"');
provider.selectPlayer(builtinPlayer);
}
}
_logger.log('AndroidAuto: playFromMediaId $mediaId');
try {
if (mediaId.startsWith('track|')) {
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
final parts = mediaId.split('|');
if (parts.length < 6) return;
final tProvider = parts[1];
final tItemId = parts[2];
final ctxKey = parts.sublist(3).join('|');
final trackList = _autoTrackCache[ctxKey];
if (trackList == null || trackList.isEmpty) {
_logger.log('AndroidAuto: cache miss for $ctxKey, playing single track');
await provider.playTrack(
playerId,
ma.Track(itemId: tItemId, provider: tProvider, name: ''),
);
return;
}
final index = trackList.indexWhere(
(t) => t.provider == tProvider && t.itemId == tItemId,
);
await provider.playTracks(
playerId,
trackList,
startIndex: index < 0 ? 0 : index,
);
return;
}
if (mediaId.startsWith('radio|')) {
// Format: radio|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) await provider.checkAndReconnect();
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play radio');
return;
}
final station = provider.radioStations.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => provider.radioStationsUnfiltered.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => throw Exception('Radio station not found: $mediaId'),
),
);
await provider.api!.playRadioStation(playerId, station);
return;
}
if (mediaId.startsWith('audiobook|')) {
// Format: audiobook|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) {
await provider.checkAndReconnect();
}
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play audiobook');
return;
}
final book = SyncService.instance.cachedAudiobooks.firstWhere(
(b) => b.provider == parts[1] && b.itemId == parts[2],
orElse: () => throw Exception('Audiobook not found: $mediaId'),
);
await provider.api!.playAudiobook(playerId, book);
return;
}
if (mediaId.startsWith('podcast_ep|')) {
// Format: podcast_ep|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) await provider.checkAndReconnect();
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play podcast');
return;
}
// Use provider-specific URI (e.g. spotify--xxx://podcast_episode/id)
// instead of library:// which fails for non-library items
final uri = '${parts[1]}://podcast_episode/${parts[2]}';
final episode = ma.MediaItem(
itemId: parts[2],
provider: parts[1],
name: '',
mediaType: ma.MediaType.podcastEpisode,
uri: uri,
);
_logger.log('AndroidAuto: playing podcast episode URI: $uri');
await provider.api!.playPodcastEpisode(playerId, episode);
return;
}
} catch (e) {
_logger.log('AndroidAuto: playFromMediaId error: $e');
}
}
@override
Future<List<MediaItem>> search(String query,
[Map<String, dynamic>? extras]) async {
final provider = _autoProvider;
if (provider == null || query.trim().isEmpty) return [];
try {
final results = await provider.searchWithCache(query);
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
const ctxKey = 'search||';
_autoTrackCache[ctxKey] = tracks;
return tracks
.map((t) => _autoTrackItem(provider, t, ctxKey))
.toList();
} catch (e) {
_logger.log('AndroidAuto: search error: $e');
return [];
}
}
// --- Root & category builders ---
List<MediaItem> _autoBuildRoot() {
return [
MediaItem(id: _autoIdHome, title: 'Home', playable: false,
artUri: _iconHome),
MediaItem(id: _autoIdMusic, title: 'Music', playable: false,
artUri: _iconMusic),
MediaItem(id: _autoIdAudiobooks, title: 'Audiobooks', playable: false,
artUri: _iconBook),
MediaItem(id: _autoIdPodcasts, title: 'Podcasts', playable: false,
artUri: _iconPodcast, extras: _gridHints),
MediaItem(id: _autoIdRadio, title: 'Radio', playable: false,
artUri: _iconRadio, extras: _gridHints),
];
}
// Row ID → display title mapping
static const _homeRowTitles = {
'recent-albums': 'Recent Albums',
'discover-artists': 'Discover Artists',
'discover-albums': 'Discover Albums',
'continue-listening': 'Continue Listening',
'discover-audiobooks': 'Discover Audiobooks',
'discover-series': 'Discover Series',
'favorite-albums': 'Favourite Albums',
'favorite-artists': 'Favourite Artists',
'favorite-tracks': 'Favourite Tracks',
'favorite-playlists': 'Favourite Playlists',
'favorite-radio-stations': 'Favourite Radio',
'favorite-podcasts': 'Favourite Podcasts',
};
/// Home shows subcategory folders matching the user's homescreen settings.
Future<List<MediaItem>> _autoBuildHome() async {
final rowOrder = await SettingsService.getHomeRowOrder();
final items = <MediaItem>[];
for (final rowId in rowOrder) {
if (!await _isHomeRowEnabled(rowId)) continue;
final title = _homeRowTitles[rowId];
if (title == null) continue; // skip discovery rows for now
items.add(MediaItem(
id: 'home|$rowId',
title: title,
playable: false,
extras: _gridHints,
));
}
_logger.log('AndroidAuto: Home built ${items.length} rows');
return items;
}
Future<bool> _isHomeRowEnabled(String rowId) async {
switch (rowId) {
case 'recent-albums': return SettingsService.getShowRecentAlbums();
case 'discover-artists': return SettingsService.getShowDiscoverArtists();
case 'discover-albums': return SettingsService.getShowDiscoverAlbums();
case 'continue-listening': return SettingsService.getShowContinueListeningAudiobooks();
case 'discover-audiobooks': return SettingsService.getShowDiscoverAudiobooks();
case 'discover-series': return SettingsService.getShowDiscoverSeries();
case 'favorite-albums': return SettingsService.getShowFavoriteAlbums();
case 'favorite-artists': return SettingsService.getShowFavoriteArtists();
case 'favorite-tracks': return SettingsService.getShowFavoriteTracks();
case 'favorite-playlists': return SettingsService.getShowFavoritePlaylists();
case 'favorite-radio-stations': return SettingsService.getShowFavoriteRadioStations();
case 'favorite-podcasts': return SettingsService.getShowFavoritePodcasts();
default: return false;
}
}
/// Build content for a specific home row.
Future<List<MediaItem>> _autoBuildHomeRowContent(
MusicAssistantProvider provider, String rowId) async {
switch (rowId) {
case 'recent-albums':
var albums = await provider.getRecentAlbumsWithCache();
if (albums.isEmpty) albums = SyncService.instance.cachedAlbums.take(20).toList();
return albums.take(20).map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'discover-artists':
var artists = await provider.getDiscoverArtistsWithCache();
if (artists.isEmpty) artists = SyncService.instance.cachedArtists.take(10).toList();
return artists.take(10).map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'discover-albums':
final albums = await provider.getDiscoverAlbumsWithCache();
return albums.take(20).map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'continue-listening':
final books = await provider.getInProgressAudiobooksWithCache();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name, artist: b.authorsString,
artUri: _autoArtUri(provider, b), playable: true,
)).toList();
case 'discover-audiobooks':
final books = await provider.getDiscoverAudiobooksWithCache();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name, artist: b.authorsString,
artUri: _autoArtUri(provider, b), playable: true,
)).toList();
case 'discover-series':
return _autoBuildAudiobookSeriesList(provider);
case 'favorite-albums':
final albums = await provider.getFavoriteAlbums();
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'favorite-artists':
final artists = await provider.getFavoriteArtists();
return artists.map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'favorite-tracks':
final tracks = await provider.getFavoriteTracks();
const ctxKey = 'favs||';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
case 'favorite-playlists':
final playlists = await provider.getFavoritePlaylists();
return playlists.map((p) => MediaItem(
id: 'playlist|${p.provider}|${p.itemId}',
title: p.name, artist: p.owner,
artUri: _autoArtUri(provider, p), playable: false,
)).toList();
case 'favorite-radio-stations':
final stations = await provider.getFavoriteRadioStations();
return stations.map((s) => MediaItem(
id: 'radio|${s.provider}|${s.itemId}',
title: s.name,
artUri: _autoArtUri(provider, s), playable: true,
)).toList();
case 'favorite-podcasts':
// Use cached podcasts filtered by favorite
final podcasts = SyncService.instance.cachedPodcasts
.where((p) => p.favorite == true).toList();
return podcasts.map((p) => MediaItem(
id: 'podcast|${p.provider}|${p.itemId}',
title: p.name,
artUri: _autoArtUri(provider, p), playable: false,
)).toList();
default:
return [];
}
}
List<MediaItem> _autoBuildMusicCategories() {
return [
MediaItem(id: _autoIdArtists, title: 'Artists', playable: false,
artUri: _iconArtist, extras: _gridHints),
MediaItem(id: _autoIdAlbums, title: 'Albums', playable: false,
artUri: _iconAlbum, extras: _gridHints),
MediaItem(id: _autoIdPlaylists, title: 'Playlists', playable: false,
artUri: _iconPlaylist, extras: _gridHints),
MediaItem(id: _autoIdFavorites, title: 'Favourites', playable: false,
artUri: _iconFavorite),
];
}
List<MediaItem> _autoBuildAudiobookCategories() {
return [
MediaItem(id: _autoIdAbAuthors, title: 'Authors', playable: false,
artUri: _iconArtist),
MediaItem(id: _autoIdAbBooks, title: 'Books', playable: false,
artUri: _iconBook, extras: _gridHints),
MediaItem(id: _autoIdAbSeries, title: 'Series', playable: false,
artUri: _iconBook, extras: _gridHints),
];
}
// --- Music builders ---
Future<List<MediaItem>> _autoBuildFavorites(
MusicAssistantProvider provider) async {
final tracks = await provider.getFavoriteTracks();
_logger.log('AndroidAuto: Favorites returned ${tracks.length} tracks');
const ctxKey = 'favs||';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
List<MediaItem> _autoBuildPlaylistList(MusicAssistantProvider provider) {
final playlists = SyncService.instance.cachedPlaylists;
_logger.log('AndroidAuto: Playlists: ${playlists.length}');
return playlists.map((p) => MediaItem(
id: 'playlist|${p.provider}|${p.itemId}',
title: p.name,
artist: p.owner,
artUri: _autoArtUri(provider, p),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildPlaylistTracks(
MusicAssistantProvider provider, String plProvider, String plItemId) async {
final tracks =
await provider.getPlaylistTracksWithCache(plProvider, plItemId);
final ctxKey = 'plist|$plProvider|$plItemId';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
List<MediaItem> _autoBuildArtistList(MusicAssistantProvider provider) {
final artists = SyncService.instance.cachedArtists;
_logger.log('AndroidAuto: Artists: ${artists.length}');
return artists.map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildArtistAlbums(
MusicAssistantProvider provider, String artistName) async {
var albums = await provider.getArtistAlbumsWithCache(artistName);
if (albums.isEmpty) {
// Fallback to library if API unavailable
albums = provider.getArtistAlbumsFromLibrary(artistName);
}
_logger.log('AndroidAuto: Artist "$artistName" albums: ${albums.length}');
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name,
artist: a.artistsString,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
List<MediaItem> _autoBuildAlbumList(MusicAssistantProvider provider) {
final albums = SyncService.instance.cachedAlbums;
_logger.log('AndroidAuto: Albums: ${albums.length}');
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name,
artist: a.artistsString,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildAlbumTracks(
MusicAssistantProvider provider, String alProvider, String alItemId) async {
final tracks =
await provider.getAlbumTracksWithCache(alProvider, alItemId);
_logger.log('AndroidAuto: Album $alProvider/$alItemId tracks: ${tracks.length}');
final ctxKey = 'album|$alProvider|$alItemId';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
// --- Audiobook builders ---
List<MediaItem> _autoBuildAudiobookAuthorList(
MusicAssistantProvider provider) {
final books = SyncService.instance.cachedAudiobooks;
final seen = <String>{};
final items = <MediaItem>[];
for (final b in books) {
final author = b.authorsString;
if (seen.add(author)) {
items.add(MediaItem(
id: 'ab_author|$author',
title: author,
artUri: _autoArtUri(provider, b),
playable: false,
));
}
}
_logger.log('AndroidAuto: Audiobook authors: ${items.length}');
return items;
}
List<MediaItem> _autoBuildAuthorAudiobooks(
MusicAssistantProvider provider, String authorName) {
final books = SyncService.instance.cachedAudiobooks
.where((b) => b.authorsString == authorName)
.toList();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
Future<List<MediaItem>> _autoBuildAudiobookList(MusicAssistantProvider provider) async {
var books = SyncService.instance.cachedAudiobooks;
if (books.isEmpty) {
// Sync cache may not be loaded yet — wait for it
_logger.log('AndroidAuto: cachedAudiobooks empty, loading from cache');
await SyncService.instance.loadFromCache();
books = SyncService.instance.cachedAudiobooks;
}
_logger.log('AndroidAuto: Books: ${books.length}');
// Sort alphabetically and cap to avoid overwhelming Android Auto
books.sort((a, b) => a.name.compareTo(b.name));
return books.take(500).map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
Future<List<MediaItem>> _autoBuildAudiobookSeriesList(
MusicAssistantProvider provider) async {
final series = await provider.getDiscoverSeriesWithCache();
_logger.log('AndroidAuto: Audiobook series: ${series.length}');
final items = <MediaItem>[];
for (final s in series) {
// Use first book's artwork instead of series mosaic thumbnail
Uri? artUri;
var cachedBooks = provider.getCachedSeriesAudiobooks(s.id);
if (cachedBooks == null || cachedBooks.isEmpty) {
// Fetch books for this series (result is cached for future use)
try {
cachedBooks = await provider.getSeriesAudiobooksWithCache(s.id);
} catch (_) {}
}
if (cachedBooks != null && cachedBooks.isNotEmpty) {
artUri = _autoArtUri(provider, cachedBooks.first);
}
items.add(MediaItem(
id: 'ab_series|${s.id}',
title: s.name,
artUri: artUri,
playable: false,
));
}
return items;
}
Future<List<MediaItem>> _autoBuildSeriesAudiobooks(
MusicAssistantProvider provider, String seriesPath) async {
final books = await provider.getSeriesAudiobooksWithCache(seriesPath);
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
// --- Podcast builders ---
List<MediaItem> _autoBuildPodcastList(MusicAssistantProvider provider) {
final podcasts = SyncService.instance.cachedPodcasts;
_logger.log('AndroidAuto: Podcasts: ${podcasts.length}');
return podcasts.map((p) => MediaItem(
id: 'podcast|${p.provider}|${p.itemId}',
title: p.name,
artUri: _autoArtUri(provider, p),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildPodcastEpisodes(
MusicAssistantProvider provider, String podProvider, String podItemId) async {
final episodes = await provider.getPodcastEpisodesWithCache(
podItemId,
provider: podProvider,
);
return episodes.map((e) => MediaItem(
id: 'podcast_ep|${e.provider}|${e.itemId}',
title: e.name,
duration: e.duration,
artUri: _autoArtUri(provider, e),
playable: true,
)).toList();
}
// --- Radio builder ---
Future<List<MediaItem>> _autoBuildRadioList(MusicAssistantProvider provider) async {
// Radio stations are lazily loaded — trigger load if empty
if (provider.radioStations.isEmpty) {
_logger.log('AndroidAuto: Radio empty, loading stations');
await provider.loadRadioStations();
}
final stations = provider.radioStations;
_logger.log('AndroidAuto: Radio stations: ${stations.length}');
return stations.map((s) => MediaItem(
id: 'radio|${s.provider}|${s.itemId}',
title: s.name,
artUri: _autoArtUri(provider, s),
playable: true,
)).toList();
}
// --- Helpers ---
MediaItem _autoTrackItem(
MusicAssistantProvider provider, ma.Track t, String ctxKey) {
return MediaItem(
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
title: t.name,
artist: t.artistsString,
album: t.album?.name,
duration: t.duration,
artUri: _autoArtUri(provider, t),
playable: true,
);
}
Uri? _autoArtUri(MusicAssistantProvider provider, ma.MediaItem item) {
final url = provider.getImageUrl(item, size: 256);
return url != null ? Uri.tryParse(url) : null;
}
// ---------------------------------------------------------------------------
/// Dispose of resources and cancel all subscriptions
Future<void> dispose() async {
await _interruptionSubscription?.cancel();
+284 -28
View File
@@ -1,7 +1,7 @@
import 'dart:convert';
import '../models/media_item.dart';
import '../models/recommendation_folder.dart';
import '../models/player.dart';
import '../models/recommendation_folder.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../constants/timings.dart';
@@ -11,6 +11,7 @@ import '../constants/timings.dart';
class CacheService {
final DebugLogger _logger = DebugLogger();
bool _homeRowsLoaded = false;
Future<void>? _homeRowsLoadFuture;
// Cache size limits to prevent unbounded memory growth
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
@@ -21,14 +22,14 @@ class CacheService {
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
List<RecommendationFolder>? _cachedExternalMixes;
List<RecommendationFolder>? _cachedDiscoveryFolders;
List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
DateTime? _externalMixesLastFetched;
DateTime? _discoveryFoldersLastFetched;
DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched;
@@ -54,10 +55,21 @@ class CacheService {
final Map<String, Track?> _playerTrackCache = {};
final Map<String, DateTime> _playerTrackCacheTime = {};
// Last-known track cache (survives idle-out, for resume after Chromecast idle)
final Map<String, Track> _lastKnownTrackCache = {};
// ============================================================================
// HOME SCREEN ROW CACHING
// ============================================================================
/// Ensure home rows are loaded from database before accessing cache
/// This ensures the cache is populated on first access if not already loaded
Future<void> ensureHomeRowsLoaded() async {
if (!_homeRowsLoaded) {
await loadHomeRowsFromDatabase();
}
}
/// Check if recent albums cache is valid
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -72,6 +84,11 @@ class CacheService {
/// Set cached recent albums
void setCachedRecentAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty recent albums result');
return;
}
_cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums');
@@ -93,6 +110,11 @@ class CacheService {
/// Set cached discover artists
void setCachedDiscoverArtists(List<Artist> artists) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (artists.isEmpty) {
_logger.log('⚠️ Not caching empty discover artists result');
return;
}
_cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists');
@@ -114,6 +136,11 @@ class CacheService {
/// Set cached discover albums
void setCachedDiscoverAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty discover albums result');
return;
}
_cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums');
@@ -121,25 +148,30 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if external mixes cache is valid
bool isExternalMixesCacheValid({bool forceRefresh = false}) {
/// Check if discovery folders cache is valid
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedExternalMixes != null &&
_externalMixesLastFetched != null &&
now.difference(_externalMixesLastFetched!) < Timings.homeRowCacheDuration;
return _cachedDiscoveryFolders != null &&
_discoveryFoldersLastFetched != null &&
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached external mixes
List<RecommendationFolder>? getCachedExternalMixes() => _cachedExternalMixes;
/// Get cached discovery folders
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
/// Set cached external mixes
void setCachedExternalMixes(List<RecommendationFolder> mixes) {
_cachedExternalMixes = mixes;
_externalMixesLastFetched = DateTime.now();
_logger.log('✅ Cached ${mixes.length} external mix shelves');
/// Set cached discovery folders
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (folders.isEmpty) {
_logger.log('⚠️ Not caching empty discovery folders result');
return;
}
_cachedDiscoveryFolders = folders;
_discoveryFoldersLastFetched = DateTime.now();
_logger.log('✅ Cached ${folders.length} discovery folders');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('external_mixes', mixes.map((m) => m.toJson()).toList());
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid
@@ -156,6 +188,11 @@ class CacheService {
/// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty in-progress audiobooks result');
return;
}
_cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
@@ -175,6 +212,11 @@ class CacheService {
/// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty discover audiobooks result');
return;
}
_cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
@@ -194,6 +236,11 @@ class CacheService {
/// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
// Don't cache empty results
if (series.isEmpty) {
_logger.log('⚠️ Not caching empty discover series result');
return;
}
_cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series');
@@ -215,7 +262,7 @@ class CacheService {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_externalMixesLastFetched = null;
_discoveryFoldersLastFetched = null;
_inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null;
@@ -245,8 +292,6 @@ class CacheService {
_discoverArtistsLastFetched = null;
_cachedDiscoverAlbums = null;
_discoverAlbumsLastFetched = null;
_cachedExternalMixes = null;
_externalMixesLastFetched = null;
_cachedRecentAlbums = null;
_recentAlbumsLastFetched = null;
_searchCache.clear();
@@ -277,6 +322,7 @@ class CacheService {
_albumTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
_persistDetailTracks('album', cacheKey, tracks);
}
/// Invalidate album tracks cache for a specific album
@@ -311,6 +357,7 @@ class CacheService {
_playlistTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
_persistDetailTracks('playlist', cacheKey, tracks);
}
/// Invalidate playlist tracks cache for a specific playlist
@@ -442,6 +489,14 @@ class CacheService {
_playerTrackCacheTime.remove(playerId);
}
/// Get last-known track for a player (survives idle-out)
Track? getLastKnownTrack(String playerId) => _lastKnownTrackCache[playerId];
/// Save last-known track for a player (only called when a real track is playing/paused)
void setLastKnownTrack(String playerId, Track track) {
_lastKnownTrackCache[playerId] = track;
}
// ============================================================================
// CLEAR ALL
// ============================================================================
@@ -458,7 +513,21 @@ class CacheService {
_searchCacheTime.clear();
_playerTrackCache.clear();
_playerTrackCacheTime.clear();
_audiobookDetailCache.clear();
_audiobookDetailCacheTime.clear();
_podcastEpisodesCache.clear();
_podcastEpisodesCacheTime.clear();
_logger.log('🗑️ All detail caches cleared');
// Also clear DB detail cache
() async {
try {
if (DatabaseService.instance.isInitialized) {
await DatabaseService.instance.clearDetailTrackCache();
}
} catch (e) {
_logger.log('⚠️ Failed to clear detail track DB cache: $e');
}
}();
}
/// Clear all caches
@@ -475,10 +544,22 @@ class CacheService {
// ============================================================================
/// Load home row data from database for instant display on startup
/// Returns the existing future if already loading, to prevent duplicate loads
Future<void> loadHomeRowsFromDatabase() async {
if (_homeRowsLoadFuture != null) {
// Already loading, return the existing future
return _homeRowsLoadFuture!;
}
if (_homeRowsLoaded) return;
_homeRowsLoaded = true;
// Create the load future
_homeRowsLoadFuture = _doLoadHomeRowsFromDatabase();
await _homeRowsLoadFuture;
_homeRowsLoadFuture = null; // Clear after completion
}
/// Internal implementation of home row loading
Future<void> _doLoadHomeRowsFromDatabase() async {
try {
if (!DatabaseService.instance.isInitialized) return;
@@ -527,22 +608,24 @@ class CacheService {
}
}
// Load external mixes
final mixesData = await DatabaseService.instance.getHomeRowCache('external_mixes');
if (mixesData != null) {
// Load discovery folders
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
if (foldersData != null) {
try {
final items = (jsonDecode(mixesData.itemsJson) as List)
final items = (jsonDecode(foldersData.itemsJson) as List)
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
.toList();
_cachedExternalMixes = items;
_externalMixesLastFetched = mixesData.lastUpdated;
_logger.log('📦 Loaded ${items.length} external mix shelves from database');
_cachedDiscoveryFolders = items;
_discoveryFoldersLastFetched = foldersData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discovery folders from database');
} catch (e) {
_logger.log('⚠️ Failed to parse external mixes: $e');
_logger.log('⚠️ Failed to parse discovery folders: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading home rows from database: $e');
} finally {
_homeRowsLoaded = true;
}
}
@@ -588,4 +671,177 @@ class CacheService {
_logger.log('🗑️ LRU evicted ${keysToRemove.length} cache entries');
}
}
// ============================================================================
// AUDIOBOOK DETAIL CACHING
// ============================================================================
final Map<String, String> _audiobookDetailCache = {}; // cacheKey -> JSON
final Map<String, DateTime> _audiobookDetailCacheTime = {};
bool isAudiobookDetailCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
final cacheTime = _audiobookDetailCacheTime[cacheKey];
return _audiobookDetailCache.containsKey(cacheKey) &&
cacheTime != null &&
DateTime.now().difference(cacheTime) < const Duration(minutes: 5);
}
String? getCachedAudiobookDetail(String cacheKey) => _audiobookDetailCache[cacheKey];
void setCachedAudiobookDetail(String cacheKey, String audiobookJson) {
_audiobookDetailCache[cacheKey] = audiobookJson;
_audiobookDetailCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_audiobookDetailCache, _audiobookDetailCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached audiobook detail for $cacheKey');
_persistDetailData('audiobook', cacheKey, audiobookJson);
}
Future<String?> getAudiobookDetailWithDbFallback(String cacheKey) async {
final memCached = _audiobookDetailCache[cacheKey];
if (memCached != null) return memCached;
try {
if (!DatabaseService.instance.isInitialized) return null;
final dbResult = await DatabaseService.instance.getDetailTracks('audiobook', cacheKey);
if (dbResult == null) return null;
_audiobookDetailCache[cacheKey] = dbResult.tracksJson;
_audiobookDetailCacheTime[cacheKey] = dbResult.lastFetched;
_logger.log('📦 Loaded audiobook detail from database for $cacheKey');
return dbResult.tracksJson;
} catch (e) {
_logger.log('⚠️ Failed to load audiobook detail from database: $e');
return null;
}
}
// ============================================================================
// PODCAST EPISODE CACHING
// ============================================================================
final Map<String, List<MediaItem>> _podcastEpisodesCache = {};
final Map<String, DateTime> _podcastEpisodesCacheTime = {};
bool isPodcastEpisodesCacheValid(String cacheKey, {bool forceRefresh = false}) {
if (forceRefresh) return false;
final cacheTime = _podcastEpisodesCacheTime[cacheKey];
return _podcastEpisodesCache.containsKey(cacheKey) &&
cacheTime != null &&
DateTime.now().difference(cacheTime) < const Duration(minutes: 5);
}
List<MediaItem>? getCachedPodcastEpisodes(String cacheKey) => _podcastEpisodesCache[cacheKey];
void setCachedPodcastEpisodes(String cacheKey, List<MediaItem> episodes) {
_podcastEpisodesCache[cacheKey] = episodes;
_podcastEpisodesCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_podcastEpisodesCache, _podcastEpisodesCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${episodes.length} episodes for podcast $cacheKey');
_persistDetailMediaItems('podcast', cacheKey, episodes);
}
Future<List<MediaItem>?> getPodcastEpisodesWithDbFallback(String cacheKey) async {
final memCached = _podcastEpisodesCache[cacheKey];
if (memCached != null) return memCached;
try {
if (!DatabaseService.instance.isInitialized) return null;
final dbResult = await DatabaseService.instance.getDetailTracks('podcast', cacheKey);
if (dbResult == null) return null;
final episodes = (jsonDecode(dbResult.tracksJson) as List)
.map((json) => MediaItem.fromJson(json as Map<String, dynamic>))
.toList();
_podcastEpisodesCache[cacheKey] = episodes;
_podcastEpisodesCacheTime[cacheKey] = dbResult.lastFetched;
_logger.log('📦 Loaded ${episodes.length} podcast episodes from database for $cacheKey');
return episodes;
} catch (e) {
_logger.log('⚠️ Failed to load podcast episodes from database: $e');
return null;
}
}
// ============================================================================
// DATABASE PERSISTENCE FOR DETAIL TRACKS
// ============================================================================
/// Persist detail tracks to database (fire-and-forget)
void _persistDetailTracks(String parentType, String cacheKey, List<Track> tracks) {
_persistDetailData(parentType, cacheKey, jsonEncode(tracks.map((t) => t.toJson()).toList()));
}
/// Persist detail media items to database (fire-and-forget)
void _persistDetailMediaItems(String parentType, String cacheKey, List<MediaItem> items) {
_persistDetailData(parentType, cacheKey, jsonEncode(items.map((i) => i.toJson()).toList()));
}
/// Persist raw JSON detail data to database (fire-and-forget)
void _persistDetailData(String parentType, String cacheKey, String dataJson) {
() async {
try {
if (!DatabaseService.instance.isInitialized) return;
await DatabaseService.instance.saveDetailTracks(parentType, cacheKey, dataJson);
_logger.log('💾 Persisted detail data ($parentType/$cacheKey) to database');
} catch (e) {
_logger.log('⚠️ Failed to persist detail data: $e');
}
}();
}
/// Get album tracks with database fallback (memory → DB → null)
Future<List<Track>?> getAlbumTracksWithDbFallback(String cacheKey) async {
// Check memory cache first
final memCached = _albumTracksCache[cacheKey];
if (memCached != null) return memCached;
// Try database
try {
if (!DatabaseService.instance.isInitialized) return null;
final dbResult = await DatabaseService.instance.getDetailTracks('album', cacheKey);
if (dbResult == null) return null;
final tracks = (jsonDecode(dbResult.tracksJson) as List)
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
// Warm memory cache
_albumTracksCache[cacheKey] = tracks;
_albumTracksCacheTime[cacheKey] = dbResult.lastFetched;
_logger.log('📦 Loaded ${tracks.length} album tracks from database for $cacheKey');
return tracks;
} catch (e) {
_logger.log('⚠️ Failed to load album tracks from database: $e');
return null;
}
}
/// Get playlist tracks with database fallback (memory → DB → null)
Future<List<Track>?> getPlaylistTracksWithDbFallback(String cacheKey) async {
// Check memory cache first
final memCached = _playlistTracksCache[cacheKey];
if (memCached != null) return memCached;
// Try database
try {
if (!DatabaseService.instance.isInitialized) return null;
final dbResult = await DatabaseService.instance.getDetailTracks('playlist', cacheKey);
if (dbResult == null) return null;
final tracks = (jsonDecode(dbResult.tracksJson) as List)
.map((json) => Track.fromJson(json as Map<String, dynamic>))
.toList();
// Warm memory cache
_playlistTracksCache[cacheKey] = tracks;
_playlistTracksCacheTime[cacheKey] = dbResult.lastFetched;
_logger.log('📦 Loaded ${tracks.length} playlist tracks from database for $cacheKey');
return tracks;
} catch (e) {
_logger.log('⚠️ Failed to load playlist tracks from database: $e');
return null;
}
}
}
+17
View File
@@ -323,6 +323,23 @@ class DatabaseService {
/// Clear all search history
Future<void> clearSearchHistory() => db.clearSearchHistory();
// ============================================
// Detail Track Cache Convenience Methods
// ============================================
/// Save detail tracks for an album or playlist
Future<void> saveDetailTracks(String parentType, String parentKey, String tracksJson) {
return db.saveDetailTracks(parentType, parentKey, tracksJson);
}
/// Get cached detail tracks for an album or playlist
Future<DetailTrackCacheData?> getDetailTracks(String parentType, String parentKey) {
return db.getDetailTracks(parentType, parentKey);
}
/// Clear all detail track cache
Future<void> clearDetailTrackCache() => db.clearDetailTrackCache();
// ============================================
// Cast-to-Sendspin Mapping Methods
// ============================================
+175
View File
@@ -0,0 +1,175 @@
import '../models/player.dart';
import 'debug_logger.dart';
/// Manages volume state for group players where the MA API returns null volume.
///
/// Both pre-configured speaker groups (provider == 'player_group') and dynamic
/// sync groups (ID typically starts with 'syncgroup_') don't have their own
/// volume_level in the MA API. This manager computes an effective volume from
/// group members and caches recently-set volumes to prevent slider snap-back.
class GroupVolumeManager {
final _logger = DebugLogger();
// Cache of last-set volume per group player ID.
// Prevents slider snap-back between setVolume() and next player_updated event.
final Map<String, int> _pendingVolumes = {};
// Timestamps for pending volumes - used to expire stale entries
final Map<String, DateTime> _pendingTimestamps = {};
// How long to trust a pending volume before falling back to computed average.
// For group players the API never returns a volume, so we keep the pending
// value until the computed member average converges (within tolerance) or
// until a generous timeout to avoid leaking state forever.
static const Duration _pendingTimeout = Duration(seconds: 30);
// If the computed member average is within this many points of the pending
// value, we consider the server to have confirmed it and clear the pending.
static const int _confirmationTolerance = 3;
/// Check if a player is a group player that needs computed volume.
///
/// Detects both:
/// - Pre-configured MA speaker groups (provider == 'player_group')
/// - Dynamic sync groups (have group members but API returns null volume)
bool isGroupPlayer(Player player) {
// Pre-configured MA speaker groups
if (player.provider == 'player_group') return true;
// Dynamic sync groups: have multiple group members but MA returns null volume
if (player.groupMembers != null &&
player.groupMembers!.length > 1 &&
player.volumeLevel == null) {
return true;
}
return false;
}
/// Get the effective volume for a player.
///
/// For non-group players: returns player.volumeLevel (unchanged behavior).
/// For group players: returns cached pending volume if recent, otherwise
/// computes average from group members.
///
/// [player] - The player to get volume for.
/// [allPlayers] - All available players (needed to look up group members).
///
/// Returns volume as int 0-100, or null if no volume can be determined.
int? getEffectiveVolumeLevel(Player player, List<Player> allPlayers) {
if (!isGroupPlayer(player)) {
return player.volumeLevel;
}
// Check for a pending (just-set) volume
final pending = _pendingVolumes[player.playerId];
if (pending != null) {
final timestamp = _pendingTimestamps[player.playerId];
if (timestamp != null &&
DateTime.now().difference(timestamp) < _pendingTimeout) {
// Compute member average to see if server has converged
final memberAvg = _computeGroupVolume(player, allPlayers);
if (memberAvg != null && (memberAvg - pending).abs() <= _confirmationTolerance) {
// Server confirmed — member average matches what we set
_pendingVolumes.remove(player.playerId);
_pendingTimestamps.remove(player.playerId);
_logger.debug(
'GroupVolume: Convergence confirmed for ${player.name}: '
'pending=$pending, memberAvg=$memberAvg',
context: 'GroupVolumeManager');
return memberAvg;
}
// Not yet confirmed — keep showing the pending value
_logger.debug(
'GroupVolume: Pending protection active for ${player.name}: '
'pending=$pending, memberAvg=$memberAvg, '
'elapsed=${DateTime.now().difference(timestamp).inSeconds}s',
context: 'GroupVolumeManager');
return pending;
}
// Expired after generous timeout — remove it
_logger.debug(
'GroupVolume: Pending expired for ${player.name} after ${_pendingTimeout.inSeconds}s, '
'falling back to computed average',
context: 'GroupVolumeManager');
_pendingVolumes.remove(player.playerId);
_pendingTimestamps.remove(player.playerId);
}
// Compute average from group members
return _computeGroupVolume(player, allPlayers);
}
/// Compute the average volume across all group members.
/// Recursively resolves nested group members (MA sometimes makes the first
/// member of a group also a group player, which returns null for volumeLevel).
/// Returns null if no members have a valid volume.
int? _computeGroupVolume(Player groupPlayer, List<Player> allPlayers,
{int depth = 0}) {
// Prevent infinite recursion from circular group references
if (depth > 3) return null;
final memberIds = groupPlayer.groupMembers;
if (memberIds == null || memberIds.isEmpty) {
_logger.debug('GroupVolume: No group members for ${groupPlayer.name}',
context: 'GroupVolumeManager');
return null;
}
int totalVolume = 0;
int count = 0;
for (final memberId in memberIds) {
// Skip self-references (groupMembers can include the group's own ID)
if (memberId == groupPlayer.playerId) continue;
final member =
allPlayers.where((p) => p.playerId == memberId).firstOrNull;
if (member == null) continue;
if (member.volumeLevel != null) {
totalVolume += member.volumeLevel!;
count++;
} else if (isGroupPlayer(member)) {
// Nested group player — MA returns null volumeLevel for these.
// Recursively compute from the nested group's own members.
final subVolume =
_computeGroupVolume(member, allPlayers, depth: depth + 1);
if (subVolume != null) {
totalVolume += subVolume;
count++;
_logger.debug(
'GroupVolume: Resolved nested group ${member.name} → volume $subVolume',
context: 'GroupVolumeManager');
}
}
}
if (count == 0) {
_logger.debug(
'GroupVolume: No members with volume for ${groupPlayer.name} '
'(${memberIds.length} members checked, depth=$depth)',
context: 'GroupVolumeManager');
return null;
}
final avg = (totalVolume / count).round();
_logger.debug(
'GroupVolume: ${groupPlayer.name} average=$avg from $count members '
'(total=$totalVolume, depth=$depth)',
context: 'GroupVolumeManager');
return avg;
}
/// Record that a volume was just set on a group player.
/// This prevents the slider from snapping back to 0 before the
/// MA server processes the volume change on all members.
void onVolumeSet(String playerId, int volumeLevel) {
_pendingVolumes[playerId] = volumeLevel;
_pendingTimestamps[playerId] = DateTime.now();
}
/// Clear all cached state (e.g., on disconnect)
void clear() {
_pendingVolumes.clear();
_pendingTimestamps.clear();
}
}
+65
View File
@@ -7,6 +7,11 @@ import 'debug_logger.dart';
///
/// For MA players: intercept buttons and send volume commands via API
/// For builtin player: let native volume work (don't intercept)
///
/// Also provides a volume observer for lockscreen volume changes.
/// When the app is in the background/lockscreen, dispatchKeyEvent doesn't fire,
/// so the observer watches system STREAM_MUSIC volume changes and sends
/// absolute volume values to Flutter for routing to the MA player.
class HardwareVolumeService {
static final HardwareVolumeService _instance = HardwareVolumeService._internal();
factory HardwareVolumeService() => _instance;
@@ -17,16 +22,25 @@ class HardwareVolumeService {
final _volumeUpController = StreamController<void>.broadcast();
final _volumeDownController = StreamController<void>.broadcast();
final _absoluteVolumeController = StreamController<int>.broadcast();
Stream<void> get onVolumeUp => _volumeUpController.stream;
Stream<void> get onVolumeDown => _volumeDownController.stream;
/// Stream of absolute volume values (0-100) from lockscreen hardware buttons.
/// Fires when the system STREAM_MUSIC volume changes while the volume
/// observer is active (remote/group player selected, app in background).
Stream<int> get onAbsoluteVolumeChange => _absoluteVolumeController.stream;
bool _isListening = false;
bool get isListening => _isListening;
bool _isIntercepting = false;
bool get isIntercepting => _isIntercepting;
bool _isObservingVolume = false;
bool get isObservingVolume => _isObservingVolume;
/// Initialize the service and start listening for volume button events
Future<void> init() async {
if (_isListening) return;
@@ -39,6 +53,11 @@ class HardwareVolumeService {
case 'volumeDown':
_volumeDownController.add(null);
break;
case 'absoluteVolumeChange':
// Volume changed from lockscreen/background via ContentObserver
final volume = call.arguments as int? ?? 0;
_absoluteVolumeController.add(volume);
break;
}
});
@@ -70,8 +89,53 @@ class HardwareVolumeService {
}
}
/// Start the volume observer for lockscreen/background volume changes.
/// When active, system STREAM_MUSIC volume changes are detected and sent
/// to Flutter via [onAbsoluteVolumeChange]. Optionally sets the system
/// volume to [initialVolume] (0-100) to match the MA player's current volume.
Future<void> startVolumeObserver({int? initialVolume}) async {
if (_isObservingVolume) return;
try {
await _channel.invokeMethod('startVolumeObserver', {
if (initialVolume != null) 'initialVolume': initialVolume,
});
_isObservingVolume = true;
_logger.info('Volume observer started', context: 'VolumeService');
} catch (e) {
_logger.error('Failed to start volume observer', context: 'VolumeService', error: e);
}
}
/// Stop the volume observer.
Future<void> stopVolumeObserver() async {
if (!_isObservingVolume) return;
try {
await _channel.invokeMethod('stopVolumeObserver');
_isObservingVolume = false;
_logger.info('Volume observer stopped', context: 'VolumeService');
} catch (e) {
_logger.error('Failed to stop volume observer', context: 'VolumeService', error: e);
}
}
/// Sync the system volume to match the MA player volume (0-100).
/// Used to keep the system volume HUD in sync with the MA player.
Future<void> syncSystemVolume(int maVolume) async {
try {
await _channel.invokeMethod('syncSystemVolume', {'volume': maVolume});
} catch (e) {
_logger.error('Failed to sync system volume', context: 'VolumeService', error: e);
}
}
/// Stop listening for volume button events
Future<void> dispose() async {
if (_isObservingVolume) {
await stopVolumeObserver();
}
if (!_isListening) return;
try {
@@ -86,5 +150,6 @@ class HardwareVolumeService {
// Close StreamControllers to prevent memory leaks
await _volumeUpController.close();
await _volumeDownController.close();
await _absoluteVolumeController.close();
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'debug_logger.dart';
/// Custom cache manager for album images with extended retention
class AlbumImageCacheManager extends CacheManager with ImageCacheManager {
static const key = 'albumImageCache';
static final AlbumImageCacheManager _instance = AlbumImageCacheManager._();
factory AlbumImageCacheManager() => _instance;
AlbumImageCacheManager._()
: super(Config(
key,
stalePeriod: const Duration(days: 30),
maxNrOfCacheObjects: 2000,
));
}
/// Service to prefetch album images in the background after sync
class ImagePrefetchService {
static final _logger = DebugLogger();
bool _cancelled = false;
/// Cancel any in-progress prefetch
void cancel() {
_cancelled = true;
}
/// Prefetch images in batches, skipping already-cached URLs
Future<void> prefetchImages(List<String> urls) async {
_cancelled = false;
if (urls.isEmpty) return;
final cacheManager = AlbumImageCacheManager();
int cached = 0;
int skipped = 0;
int failed = 0;
const batchSize = 10;
for (var i = 0; i < urls.length; i += batchSize) {
if (_cancelled) {
_logger.log('🖼️ Image prefetch cancelled after $cached cached, $skipped skipped');
return;
}
final batch = urls.skip(i).take(batchSize);
await Future.wait(batch.map((url) async {
try {
final fileInfo = await cacheManager.getFileFromCache(url);
if (fileInfo != null) {
skipped++;
return;
}
await cacheManager.downloadFile(url);
cached++;
} catch (_) {
failed++;
}
}));
}
_logger.log('🖼️ Image prefetch complete: $cached cached, $skipped already cached, $failed failed (${urls.length} total)');
}
}
+126 -55
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
import '../utils/lru_cache.dart';
@@ -259,8 +261,17 @@ class MetadataService {
/// Cache for album images - includes null entries for failed lookups (200 entries)
static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
/// Cache for author images (audiobooks) - includes null entries for failed lookups (100 entries)
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
/// Cache for author images (audiobooks) - includes null entries for failed lookups (500 entries)
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 500);
/// In-flight author image requests for deduplication
static final Map<String, Completer<String?>> _authorImageInFlight = {};
/// SharedPreferences key for persisted author images
static const _authorImagePrefsKey = 'author_image_urls';
/// Whether persisted author images have been loaded into memory cache
static bool _authorImageCacheWarmed = false;
/// Fetches album cover URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise
@@ -649,33 +660,122 @@ class MetadataService {
return null;
}
/// Fetches author image URL from multiple sources with fuzzy name matching
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Tries name variations if exact match fails
/// Load persisted author image URLs into memory cache on first access
static Future<void> _warmAuthorImageCache() async {
if (_authorImageCacheWarmed) return;
_authorImageCacheWarmed = true;
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_authorImagePrefsKey);
if (jsonStr != null) {
final map = (json.decode(jsonStr) as Map<String, dynamic>).cast<String, String>();
for (final entry in map.entries) {
_authorImageCache[entry.key] = entry.value;
}
_logger.log('📸 Loaded ${map.length} persisted author images');
}
} catch (e) {
_logger.warning('Failed to load persisted author images: $e', context: 'Metadata');
}
}
/// Persist an author image URL to SharedPreferences
static Future<void> _persistAuthorImage(String cacheKey, String url) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_authorImagePrefsKey);
final map = jsonStr != null
? (json.decode(jsonStr) as Map<String, dynamic>).cast<String, String>()
: <String, String>{};
map[cacheKey] = url;
// Cap at 500 entries to prevent unbounded growth
if (map.length > 500) {
final keys = map.keys.toList();
for (var i = 0; i < map.length - 500; i++) {
map.remove(keys[i]);
}
}
await prefs.setString(_authorImagePrefsKey, json.encode(map));
} catch (e) {
_logger.warning('Failed to persist author image: $e', context: 'Metadata');
}
}
/// Validate that an image URL actually returns an image
static Future<bool> _validateImageUrl(String url) async {
try {
final response = await http.head(Uri.parse(url)).timeout(const Duration(seconds: 3));
return response.statusCode == 200 || response.statusCode == 301 ||
response.statusCode == 302 || response.statusCode == 307 || response.statusCode == 308;
} catch (_) {
return false;
}
}
/// Try a single source with all name variations, return first validated result
static Future<String?> _trySourceWithVariations(
Future<String?> Function(String name) source,
List<String> variations, {
bool alreadyValidated = false,
}) async {
for (final name in variations) {
final url = await source(name);
if (url != null) {
if (alreadyValidated || await _validateImageUrl(url)) {
return url;
}
}
}
return null;
}
/// Fetches author image URL from multiple sources in parallel
/// Sources: 1. Audnexus, 2. Open Library, 3. Wikidata, 4. Wikipedia
/// All sources run in parallel; first validated result wins
static Future<String?> getAuthorImageUrl(String authorName) async {
// Warm cache from persistence on first access
await _warmAuthorImageCache();
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
}
// Deduplicate concurrent requests for the same author
if (_authorImageInFlight.containsKey(cacheKey)) {
return _authorImageInFlight[cacheKey]!.future;
}
final completer = Completer<String?>();
_authorImageInFlight[cacheKey] = completer;
try {
final result = await _fetchAuthorImage(authorName, cacheKey);
completer.complete(result);
return result;
} catch (e) {
completer.complete(null);
return null;
} finally {
_authorImageInFlight.remove(cacheKey);
}
}
/// Internal fetch logic (called once per author, deduplicated)
static Future<String?> _fetchAuthorImage(String authorName, String cacheKey) async {
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
// Try to get image for the first/primary author
if (authorName.contains(',')) {
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
// Skip if this looks like "Lastname, Firstname" format or has suffixes
// - Second part is initials (J.R.R., J.K., etc.)
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
if (authors.length == 2) {
final secondPart = authors[1].toLowerCase();
// Check if second part is a suffix or looks like initials
if (suffixes.contains(secondPart) ||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
// This is "Lastname, First" or "Name, Jr." - don't split
// "Lastname, First" or "Name, Jr." - don't split
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
// Both parts have multiple words - likely "Author1, Author2"
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
@@ -683,10 +783,8 @@ class MetadataService {
return result;
}
}
// Don't cache null here - fall through to try the full name
}
} else if (authors.length > 2) {
// Multiple commas = definitely multiple authors
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
@@ -694,55 +792,28 @@ class MetadataService {
return result;
}
}
// Don't cache null - fall through to try full name
}
}
// Generate name variations for fuzzy matching
final variations = _generateNameVariations(authorName);
// Try each source with all variations before moving to next source
// This prioritizes source quality over name variation
// Run all sources in parallel - first validated result wins
final results = await Future.wait([
_trySourceWithVariations(_tryAudnexus, variations),
_trySourceWithVariations(_tryOpenLibrary, variations, alreadyValidated: true),
_trySourceWithVariations(_tryWikidata, variations.take(2).toList()),
_trySourceWithVariations(_tryWikipedia, variations.take(3).toList()),
]);
// 1. Try Audnexus (best for audiobook authors)
for (final name in variations) {
final result = await _tryAudnexus(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
// Pick first non-null result (priority: Audnexus > OpenLibrary > Wikidata > Wikipedia)
final result = results[0] ?? results[1] ?? results[2] ?? results[3];
_authorImageCache[cacheKey] = result;
if (result != null) {
_persistAuthorImage(cacheKey, result);
}
// 2. Try Open Library
for (final name in variations) {
final result = await _tryOpenLibrary(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 3. Try Wikidata (structured data, good for finding exact person)
for (final name in variations.take(2)) {
final wikidataResult = await _tryWikidata(name);
if (wikidataResult != null) {
_authorImageCache[cacheKey] = wikidataResult;
return wikidataResult;
}
}
// 4. Try Wikipedia (search-based, good fallback)
for (final name in variations.take(3)) {
final wikiResult = await _tryWikipedia(name);
if (wikiResult != null) {
_authorImageCache[cacheKey] = wikiResult;
return wikiResult;
}
}
// Cache the null result to avoid repeated failed lookups
_authorImageCache[cacheKey] = null;
return null;
return result;
}
/// Cache for lyrics - uses bounded Map with manual size limiting (500 entries max)
+73 -86
View File
@@ -215,6 +215,7 @@ class MusicAssistantAPI {
},
onDone: () {
_logger.log('WebSocket connection closed');
_isAuthenticated = false; // Reset auth state on disconnect
_updateConnectionState(MAConnectionState.disconnected);
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.completeError(Exception('Connection closed'));
@@ -250,7 +251,7 @@ class MusicAssistantAPI {
void _handleMessage(dynamic message) {
try {
final data = jsonDecode(message as String) as Map<String, dynamic>;
_logger.log('Received message: ${data.keys}');
_logger.debug('Received message: ${data.keys}');
// Check for server info message (first message on connect)
if (data.containsKey('server_version')) {
@@ -413,8 +414,7 @@ class MusicAssistantAPI {
args: args,
);
final items = response['result'] as List<dynamic>? ??
response['items'] as List<dynamic>?;
final items = response['result'] as List<dynamic>?;
if (items == null) {
return [];
}
@@ -594,28 +594,6 @@ class MusicAssistantAPI {
}
}
// ============ RECOMMENDATIONS ============
/// Get recommendation folders (mixes, recommendations, etc.)
Future<List<RecommendationFolder>> getRecommendations() async {
try {
final response = await _sendCommand(
'music/recommendations',
);
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.whereType<Map<String, dynamic>>()
.map(RecommendationFolder.fromJson)
.toList();
} catch (e) {
_logger.log('Error getting recommendations: $e');
return [];
}
}
// ============ PODCASTS ============
/// Get all podcasts from the library
@@ -1218,6 +1196,28 @@ class MusicAssistantAPI {
}
}
// ============ RECOMMENDATIONS ============
/// Get recommendation folders (mixes, recommendations, etc.)
Future<List<RecommendationFolder>> getRecommendations() async {
try {
final response = await _sendCommand(
'music/recommendations',
);
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.whereType<Map<String, dynamic>>()
.map(RecommendationFolder.fromJson)
.toList();
} catch (e) {
_logger.log('Error getting recommendations: $e');
return [];
}
}
/// Get the audiobookshelf provider instance ID
Future<String?> getAudiobookshelfProviderId() async {
try {
@@ -1466,9 +1466,10 @@ class MusicAssistantAPI {
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null})
.catchError((_) => <String, dynamic>{'result': null}));
final results = await Future.wait(futures, eagerError: false);
final results = await Future.wait(futures);
for (final trackResponse in results) {
if (albumUris.length >= limit) break;
@@ -1505,9 +1506,10 @@ class MusicAssistantAPI {
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null})
.catchError((_) => <String, dynamic>{'result': null}));
final results = await Future.wait(futures, eagerError: false);
final results = await Future.wait(futures);
for (final albumResponse in results) {
try {
@@ -1619,36 +1621,28 @@ class MusicAssistantAPI {
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
return await RetryHelper.retryNetwork(
operation: () async {
try {
final response = await _sendCommand(
'music/albums/album_tracks',
args: {
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
try {
final response = await _sendCommand(
'music/albums/album_tracks',
args: {
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('No result for album tracks');
return <Track>[];
}
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('No result for album tracks');
return <Track>[];
}
return items
.map((item) => Track.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting album tracks: $e');
return <Track>[];
}
},
).catchError((e) {
_logger.log('Error getting album tracks after retries: $e');
return <Track>[];
});
return items
.map((item) => Track.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting album tracks: $e');
rethrow;
}
}
/// Get playlists
@@ -1709,35 +1703,28 @@ class MusicAssistantAPI {
/// Get playlist tracks
Future<List<Track>> getPlaylistTracks(String provider, String itemId) async {
return await RetryHelper.retryNetwork(
operation: () async {
try {
final response = await _sendCommand(
'music/playlists/playlist_tracks',
args: {
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
try {
final response = await _sendCommand(
'music/playlists/playlist_tracks',
args: {
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('No result for playlist tracks');
return <Track>[];
}
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('No result for playlist tracks');
return <Track>[];
}
return items
.map((item) => Track.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting playlist tracks: $e');
return <Track>[];
}
},
).catchError((e) {
_logger.log('Error getting playlist tracks after retries: $e');
return <Track>[];
});
return items
.map((item) => Track.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting playlist tracks: $e');
rethrow;
}
}
// Favorites
@@ -1994,7 +1981,7 @@ class MusicAssistantAPI {
currentIndex = queueResult['current_index'] as int?;
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
repeatMode = queueResult['repeat_mode'] as String?;
_logger.log('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
_logger.debug('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
final currentItemName = currentItemData?['name'] as String?;
@@ -2030,7 +2017,7 @@ class MusicAssistantAPI {
}
if (items.isEmpty) {
_logger.log('⚠️ Queue is empty or all items failed to parse');
_logger.debug('⚠️ Queue is empty or all items failed to parse');
return null;
}
+12 -1
View File
@@ -88,6 +88,9 @@ class PcmAudioPlayer {
// Each chunk is ~4KB, so max ~500 chunks
static const int _maxBufferChunks = 500;
// Software volume gain (0.0 = silent, 1.0 = full volume)
double _volumeGain = 1.0;
// Stats
int _framesPlayed = 0;
int _bytesPlayed = 0;
@@ -113,6 +116,11 @@ class PcmAudioPlayer {
_state == PcmPlayerState.resuming ||
_state == PcmPlayerState.stopping;
/// Set software volume gain (0.0 = silent, 1.0 = full). Applied to PCM samples at feed time.
void setVolumeGain(double gain) {
_volumeGain = gain.clamp(0.0, 1.0);
}
/// Check if feeding should be blocked
bool get _shouldBlockFeeding => isTransitioning || _state == PcmPlayerState.paused;
@@ -360,7 +368,10 @@ class PcmAudioPlayer {
// Convert Uint8List (raw bytes) to Int16 samples
// Sendspin sends 16-bit little-endian PCM
final samples = _bytesToInt16List(chunk);
final rawSamples = _bytesToInt16List(chunk);
final samples = _volumeGain == 1.0
? rawSamples
: rawSamples.map((s) => (s * _volumeGain).round().clamp(-32768, 32767)).toList();
// Check state again before the async feed call
if (samples.isNotEmpty && !_shouldBlockFeeding) {
+9 -9
View File
@@ -100,7 +100,7 @@ class PositionTracker {
Duration? duration,
double? serverTimestamp,
}) {
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
_logger.debug('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
final bool playerChanged = _playerId != playerId;
final bool playStateChanged = _isPlaying != isPlaying;
final bool durationChanged = duration != null && _duration != duration;
@@ -179,11 +179,11 @@ class PositionTracker {
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
: anchorPos; // Use server position for other cases
_logger.log('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_logger.debug('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_anchorPosition = newAnchorPos;
_anchorTime = DateTime.now();
} else if (positionDiff > 2) {
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
_logger.debug('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
}
_isPlaying = isPlaying;
@@ -227,7 +227,7 @@ class PositionTracker {
/// Clear tracker state (e.g., when disconnecting)
void clear() {
_logger.log('⏱️ PositionTracker.clear() called');
_logger.debug('⏱️ PositionTracker.clear() called');
_stopInterpolationTimer();
_playerId = null;
_isPlaying = false;
@@ -238,17 +238,17 @@ class PositionTracker {
void _startInterpolationTimer() {
if (_interpolationTimer != null) {
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
_logger.debug('⏱️ PositionTracker: Timer already running, skipping start');
return;
}
_logger.log('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_logger.debug('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
// Log every 4 ticks (1 second) if no emission happened
if (timer.tick % 4 == 0) {
final pos = currentPosition;
if (pos.inSeconds == _lastEmittedSeconds) {
_logger.log('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
_logger.debug('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
}
}
_emitPosition();
@@ -259,7 +259,7 @@ class PositionTracker {
}
void _stopInterpolationTimer() {
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_logger.debug('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_interpolationTimer?.cancel();
_interpolationTimer = null;
}
@@ -271,7 +271,7 @@ class PositionTracker {
// Only emit if second changed (reduces unnecessary updates)
if (seconds != _lastEmittedSeconds) {
_lastEmittedSeconds = seconds;
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_logger.debug('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_positionController.add(pos);
}
}
+24 -3
View File
@@ -249,7 +249,7 @@ class SendspinService {
'name': _playerName,
'version': 1, // Protocol version (integer)
'supported_roles': ['player@v1'],
'player_support': {
'player@v1_support': {
'supported_formats': [
{
'codec': 'pcm',
@@ -427,7 +427,25 @@ class SendspinService {
}
break;
case 'server/command':
final cmdPayload = data['payload'] as Map<String, dynamic>?;
final playerCmd = cmdPayload?['player'] as Map<String, dynamic>?;
final command = playerCmd?['command'] as String?;
if (command == 'volume') {
final level = playerCmd?['volume'] as int?;
if (level != null) {
_volume = level;
onVolume?.call(level);
}
} else if (command == 'mute') {
final muted = playerCmd?['mute'] as bool? ?? false;
_isMuted = muted;
onVolume?.call(muted ? 0 : _volume);
}
break;
case 'volume':
// Legacy protocol - kept for compatibility
final level = data['level'] as int?;
if (level != null) {
_volume = level;
@@ -523,7 +541,7 @@ class SendspinService {
try {
final json = jsonEncode(message);
_logger.log('Sendspin: Sending message: ${message['type']}');
_logger.debug('Sendspin: Sending message: ${message['type']}');
_channel!.sink.add(json);
} catch (e) {
_logger.log('Sendspin: Error sending message: $e');
@@ -657,7 +675,10 @@ class SendspinService {
void _updateState(SendspinConnectionState newState) {
if (_state != newState) {
_state = newState;
_stateController.add(newState);
// Don't try to emit if disposed or controller is closed
if (!_isDisposed && !_stateController.isClosed) {
_stateController.add(newState);
}
}
}
+51 -15
View File
@@ -94,7 +94,7 @@ class SettingsService {
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
static const String _keyShowExternalMixes = 'show_external_mixes';
static const String _keyShowDiscoveryFolders = 'show_discovery_folders';
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
static const String _keyShowDiscoverSeries = 'show_discover_series';
@@ -107,21 +107,11 @@ class SettingsService {
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
// Default row order
// Default row order (only these 3 rows enabled by default on first launch)
static const List<String> defaultHomeRowOrder = [
'recent-albums',
'discover-artists',
'discover-albums',
'external-mixes',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
// View Mode Settings
@@ -179,6 +169,9 @@ class SettingsService {
// Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
static const String _keyDiscoveryRowPreferences = 'discovery_row_preferences';
static Future<String?> getServerUrl() async {
final prefs = await _getPrefs();
return prefs.getString(_keyServerUrl);
@@ -393,8 +386,9 @@ class SettingsService {
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
static Future<bool> getShowExternalMixes() => _getBool(_keyShowExternalMixes, defaultValue: false);
static Future<void> setShowExternalMixes(bool show) => _setBool(_keyShowExternalMixes, show);
// Home Screen Discovery Folders (default off - provider mixes like Tidal)
static Future<bool> getShowDiscoveryFolders() => _getBool(_keyShowDiscoveryFolders, defaultValue: false);
static Future<void> setShowDiscoveryFolders(bool show) => _setBool(_keyShowDiscoveryFolders, show);
// Home Screen Audiobook Rows (default off - optional)
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
@@ -944,7 +938,7 @@ class SettingsService {
/// for fine-grained control (10x more precise)
static Future<bool> getVolumePrecisionMode() async {
final prefs = await _getPrefs();
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
return prefs.getBool(_keyVolumePrecisionMode) ?? false;
}
/// Set whether volume precision mode is enabled
@@ -981,4 +975,46 @@ class SettingsService {
cache[podcastId] = url;
await setPodcastCoverCache(cache);
}
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
/// Get all discovery row preferences as a map (itemId -> enabled)
/// Defaults to all enabled (true) if not set
static Future<Map<String, bool>> getDiscoveryRowPreferences() async {
final prefs = await _getPrefs();
final json = prefs.getString(_keyDiscoveryRowPreferences);
if (json == null) return {}; // Empty map = all enabled by default
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return decoded.map((key, value) => MapEntry(key, value as bool));
} catch (_) {
return {};
}
}
/// Set all discovery row preferences at once
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async {
final sharedPrefs = await _getPrefs();
await sharedPrefs.setString(_keyDiscoveryRowPreferences, jsonEncode(prefs));
}
/// Get whether a specific discovery row is enabled
/// Returns false if not explicitly set (default off)
static Future<bool> getDiscoveryRowPreference(String itemId) async {
final allPrefs = await getDiscoveryRowPreferences();
return allPrefs[itemId] ?? false; // Default to disabled
}
/// Set whether a specific discovery row is enabled
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async {
final allPrefs = await getDiscoveryRowPreferences();
allPrefs[itemId] = enabled;
await setDiscoveryRowPreferences(allPrefs);
}
/// Clear all discovery row preferences (reverts to all enabled)
static Future<void> clearDiscoveryRowPreferences() async {
final prefs = await _getPrefs();
await prefs.remove(_keyDiscoveryRowPreferences);
}
}
+66 -9
View File
@@ -43,6 +43,7 @@ class SyncService with ChangeNotifier {
List<Artist> _cachedArtists = [];
List<Audiobook> _cachedAudiobooks = [];
List<Playlist> _cachedPlaylists = [];
List<MediaItem> _cachedPodcasts = [];
// Source provider tracking for client-side filtering
// Maps itemId -> list of provider instance IDs that provided the item
@@ -50,7 +51,6 @@ class SyncService with ChangeNotifier {
Map<String, List<String>> _artistSourceProviders = {};
Map<String, List<String>> _audiobookSourceProviders = {};
Map<String, List<String>> _playlistSourceProviders = {};
// Getters
SyncStatus get status => _status;
String? get lastError => _lastError;
@@ -60,15 +60,16 @@ class SyncService with ChangeNotifier {
List<Artist> get cachedArtists => _cachedArtists;
List<Audiobook> get cachedAudiobooks => _cachedAudiobooks;
List<Playlist> get cachedPlaylists => _cachedPlaylists;
List<MediaItem> get cachedPodcasts => _cachedPodcasts;
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty ||
_cachedPodcasts.isNotEmpty;
// Source provider getters for client-side filtering
Map<String, List<String>> get albumSourceProviders => _albumSourceProviders;
Map<String, List<String>> get artistSourceProviders => _artistSourceProviders;
Map<String, List<String>> get audiobookSourceProviders => _audiobookSourceProviders;
Map<String, List<String>> get playlistSourceProviders => _playlistSourceProviders;
/// Load library data from database cache (instant)
/// Call this on app startup for immediate data
/// Also loads source provider info for client-side filtering
@@ -145,8 +146,20 @@ class SyncService with ChangeNotifier {
}
}
// Load podcasts from cache (no source provider tracking - API doesn't support filtering)
final podcastData = await _db.getCachedItems('podcast');
_cachedPodcasts = [];
for (final data in podcastData) {
try {
_cachedPodcasts.add(MediaItem.fromJson(data));
} catch (e) {
_logger.log('⚠️ Failed to parse cached podcast: $e');
}
}
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists, '
'${_cachedPodcasts.length} podcasts from cache');
_logger.log('📦 Source providers: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists tracked');
notifyListeners();
} catch (e) {
@@ -173,13 +186,16 @@ class SyncService with ChangeNotifier {
}
// Check if sync is needed (default: every 5 minutes)
if (!force) {
// Always sync if cache is empty (no albums AND no artists loaded)
final cacheEmpty = _cachedAlbums.isEmpty && _cachedArtists.isEmpty;
if (!force && !cacheEmpty) {
final albumsNeedSync = await _db.needsSync('albums', maxAge: const Duration(minutes: 5));
final artistsNeedSync = await _db.needsSync('artists', maxAge: const Duration(minutes: 5));
final audiobooksNeedSync = await _db.needsSync('audiobooks', maxAge: const Duration(minutes: 5));
final playlistsNeedSync = await _db.needsSync('playlists', maxAge: const Duration(minutes: 5));
final podcastsNeedSync = await _db.needsSync('podcasts', maxAge: const Duration(minutes: 5));
if (!albumsNeedSync && !artistsNeedSync && !audiobooksNeedSync && !playlistsNeedSync) {
if (!albumsNeedSync && !artistsNeedSync && !audiobooksNeedSync && !playlistsNeedSync && !podcastsNeedSync) {
_logger.log('✅ Cache is fresh, skipping sync');
return;
}
@@ -205,6 +221,7 @@ class SyncService with ChangeNotifier {
await _db.clearCacheForType('artist');
await _db.clearCacheForType('audiobook');
await _db.clearCacheForType('playlist');
await _db.clearCacheForType('podcast');
}
// Build NEW source tracking maps - don't modify existing until sync is complete
@@ -249,6 +266,9 @@ class SyncService with ChangeNotifier {
_logger.log(' 📡 Syncing provider: $providerId');
// Fetch from this specific provider
// Note: Tracks are NOT fetched during background sync to avoid
// overwhelming provider rate limits (especially Spotify).
// Tracks are loaded on-demand when the user visits the tracks tab.
final results = await Future.wait([
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
@@ -261,7 +281,7 @@ class SyncService with ChangeNotifier {
final audiobooks = results[2] as List<Audiobook>;
final playlists = results[3] as List<Playlist>;
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks, ${playlists.length} playlists from $providerId');
// Add to maps and track source provider in NEW tracking maps
for (final album in albums) {
@@ -323,20 +343,31 @@ class SyncService with ChangeNotifier {
final audiobooks = audiobookMap.values.toList();
final playlists = playlistMap.values.toList();
// Fetch podcasts separately (API doesn't support providerInstanceIds)
List<MediaItem> podcasts = [];
try {
podcasts = await api.getPodcasts(limit: 100);
} catch (e) {
_logger.log('⚠️ Failed to fetch podcasts: $e');
}
_logger.log('📥 Total: ${albums.length} albums, ${artists.length} artists, '
'${audiobooks.length} audiobooks, ${playlists.length} playlists');
'${audiobooks.length} audiobooks, ${playlists.length} playlists, '
'${podcasts.length} podcasts');
// Save to database cache with source provider info (using NEW tracking maps)
await _saveAlbumsToCache(albums, newAlbumSourceProviders);
await _saveArtistsToCache(artists, newArtistSourceProviders);
await _saveAudiobooksToCache(audiobooks, newAudiobookSourceProviders);
await _savePlaylistsToCache(playlists, newPlaylistSourceProviders);
await _savePodcastsToCache(podcasts);
// Update sync metadata
await _db.updateSyncMetadata('albums', albums.length);
await _db.updateSyncMetadata('artists', artists.length);
await _db.updateSyncMetadata('audiobooks', audiobooks.length);
await _db.updateSyncMetadata('playlists', playlists.length);
await _db.updateSyncMetadata('podcasts', podcasts.length);
// Update in-memory cache
if (isPartialSync) {
@@ -370,6 +401,9 @@ class SyncService with ChangeNotifier {
_cachedAudiobooks = audiobooks;
_cachedPlaylists = playlists;
}
// Podcasts always full replace (no per-provider tracking)
_cachedPodcasts = podcasts;
// Atomically swap in the new source tracking maps
// This ensures filtering always sees complete data, never partial
_albumSourceProviders = newAlbumSourceProviders;
@@ -472,6 +506,24 @@ class SyncService with ChangeNotifier {
}
}
/// Save podcasts to database cache (batched, no source provider tracking)
Future<void> _savePodcastsToCache(List<MediaItem> podcasts) async {
final batchItems = <BatchCacheItem>[];
for (final podcast in podcasts) {
batchItems.add(BatchCacheItem(
itemType: 'podcast',
itemId: podcast.itemId,
data: jsonEncode(podcast.toJson()),
));
}
try {
await _db.batchCacheItems(batchItems);
_logger.log('💾 Batch saved ${podcasts.length} podcasts to cache');
} catch (e) {
_logger.log('⚠️ Failed to batch cache podcasts: $e');
}
}
/// Force a fresh sync (for pull-to-refresh)
/// [providerInstanceIds] - optional list of provider IDs to filter by (null = all providers)
Future<void> forceSync(MusicAssistantAPI api, {List<String>? providerInstanceIds}) async {
@@ -487,6 +539,7 @@ class SyncService with ChangeNotifier {
_cachedArtists = [];
_cachedAudiobooks = [];
_cachedPlaylists = [];
_cachedPodcasts = [];
_albumSourceProviders = {};
_artistSourceProviders = {};
_audiobookSourceProviders = {};
@@ -572,9 +625,13 @@ class SyncService with ChangeNotifier {
/// Get playlists (from cache or empty if not loaded)
List<Playlist> getPlaylists() => _cachedPlaylists;
/// Get podcasts (from cache or empty if not loaded)
List<MediaItem> getPodcasts() => _cachedPodcasts;
/// Check if we have data available (from cache or sync)
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty ||
_cachedPodcasts.isNotEmpty;
/// Update cached albums with sorted data from provider
/// Used when sort order changes - preserves source provider tracking
+16 -9
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../services/image_prefetch_service.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../screens/album_details_screen.dart';
@@ -59,15 +60,20 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
// Defer status initialization to after the build frame completes.
// setLibraryStatus() calls notifyListeners() synchronously, which triggers
// setState() in listening widgets — illegal during the build phase.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
});
_initFallbackImage();
}
@@ -372,6 +378,7 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
cacheManager: AlbumImageCacheManager(),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
+20 -3
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../services/image_prefetch_service.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
@@ -14,6 +15,8 @@ class AlbumRow extends StatefulWidget {
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Album>? Function()? getCachedAlbums;
/// Optional: signal to trigger in-place reload without destroying the widget
final ValueNotifier<int>? refreshSignal;
const AlbumRow({
super.key,
@@ -22,6 +25,7 @@ class AlbumRow extends StatefulWidget {
this.heroTagSuffix,
this.rowHeight,
this.getCachedAlbums,
this.refreshSignal,
});
@override
@@ -46,6 +50,19 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
_isLoading = false;
}
_loadAlbums();
widget.refreshSignal?.addListener(_onRefreshSignal);
}
void _onRefreshSignal() {
if (!mounted) return;
_hasLoaded = false;
_loadAlbums();
}
@override
void dispose() {
widget.refreshSignal?.removeListener(_onRefreshSignal);
super.dispose();
}
Future<void> _loadAlbums() async {
@@ -85,7 +102,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
CachedNetworkImageProvider(imageUrl, cacheManager: AlbumImageCacheManager()),
context,
).catchError((_) {
// Silently ignore precache errors
@@ -114,7 +131,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 52.0;
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -158,7 +175,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+14 -9
View File
@@ -59,15 +59,20 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
// Defer status initialization to after the build frame completes.
// setLibraryStatus() calls notifyListeners() synchronously, which triggers
// setState() in listening widgets — illegal during the build phase.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
});
// Fetch fallback image once in initState, not during build
_initFallbackImage();
}
+18 -2
View File
@@ -14,6 +14,8 @@ class ArtistRow extends StatefulWidget {
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Artist>? Function()? getCachedArtists;
/// Optional: signal to trigger in-place reload without destroying the widget
final ValueNotifier<int>? refreshSignal;
const ArtistRow({
super.key,
@@ -22,6 +24,7 @@ class ArtistRow extends StatefulWidget {
this.heroTagSuffix,
this.rowHeight,
this.getCachedArtists,
this.refreshSignal,
});
@override
@@ -46,6 +49,19 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
_isLoading = false;
}
_loadArtists();
widget.refreshSignal?.addListener(_onRefreshSignal);
}
void _onRefreshSignal() {
if (!mounted) return;
_hasLoaded = false;
_loadArtists();
}
@override
void dispose() {
widget.refreshSignal?.removeListener(_onRefreshSignal);
super.dispose();
}
Future<void> _loadArtists() async {
@@ -114,7 +130,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 52.0;
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
@@ -154,7 +170,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+5 -3
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../services/image_prefetch_service.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
@@ -95,7 +96,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
final imageUrl = maProvider.api?.getImageUrl(audiobook, size: 256);
if (imageUrl != null) {
precacheImage(
CachedNetworkImageProvider(imageUrl),
CachedNetworkImageProvider(imageUrl, cacheManager: AlbumImageCacheManager()),
context,
).catchError((_) {
// Silently ignore precache errors
@@ -121,7 +122,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
);
}
const textAreaHeight = 52.0;
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
@@ -162,7 +163,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 52.0;
const titleHeight = 44.0;
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
@@ -449,6 +450,7 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
cacheManager: AlbumImageCacheManager(),
// PERF: Duration.zero for hero-wrapped images
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
+4
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// A wrapper around CachedNetworkImage for consistent image caching throughout the app.
///
@@ -18,6 +19,7 @@ class CachedImage extends StatelessWidget {
final int? cacheWidth;
final int? cacheHeight;
final BorderRadius? borderRadius;
final BaseCacheManager? cacheManager;
const CachedImage({
super.key,
@@ -30,6 +32,7 @@ class CachedImage extends StatelessWidget {
this.cacheWidth,
this.cacheHeight,
this.borderRadius,
this.cacheManager,
});
@override
@@ -43,6 +46,7 @@ class CachedImage extends StatelessWidget {
fit: fit,
memCacheWidth: cacheWidth,
memCacheHeight: cacheHeight,
cacheManager: cacheManager,
fadeInDuration: const Duration(milliseconds: 150),
fadeOutDuration: const Duration(milliseconds: 150),
placeholder: (context, url) => placeholder ?? Container(
+282
View File
@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'playlist_card.dart';
import 'radio_station_card.dart';
class DiscoveryRow extends StatefulWidget {
final String title;
final Future<List<MediaItem>> Function() loadItems;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<MediaItem>? Function()? getCachedItems;
const DiscoveryRow({
super.key,
required this.title,
required this.loadItems,
this.heroTagSuffix,
this.rowHeight,
this.getCachedItems,
});
@override
State<DiscoveryRow> createState() => _DiscoveryRowState();
}
class _DiscoveryRowState extends State<DiscoveryRow> with AutomaticKeepAliveClientMixin {
List<MediaItem> _items = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedItems?.call();
if (cached != null && cached.isNotEmpty) {
_items = cached;
_isLoading = false;
}
_loadItems();
}
Future<void> _loadItems() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshItems = await widget.loadItems();
if (mounted && freshItems.isNotEmpty) {
setState(() {
_items = freshItems;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheImages(freshItems);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache images so hero animations are smooth on first tap
void _precacheImages(List<MediaItem> items) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final itemsToCache = items.take(10);
for (final item in itemsToCache) {
final imageUrl = maProvider.api?.getImageUrl(item, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_items.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_items.isEmpty) {
return Center(
child: Text(
S.of(context)!.noAlbumsFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px subtitle = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _items.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final item = _items[index];
final key = ValueKey(item.uri ?? item.itemId);
Widget card;
if (item is Playlist) {
card = PlaylistCard(
playlist: item,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else if (item.mediaType == MediaType.radio) {
card = RadioStationCard(
radioStation: item,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else {
// Fallback for other media types
card = _buildFallbackCard(item, cardWidth);
}
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: key,
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: card,
),
);
},
),
);
}
Widget _buildFallbackCard(MediaItem item, double width) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
width: width,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_getIconForMediaType(item.mediaType),
size: 48,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
const SizedBox(height: 8),
Text(
item.name,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
IconData _getIconForMediaType(MediaType type) {
switch (type) {
case MediaType.album:
return Icons.album;
case MediaType.artist:
return Icons.person;
case MediaType.track:
return Icons.music_note;
case MediaType.playlist:
return Icons.playlist_play;
case MediaType.radio:
return Icons.radio;
case MediaType.audiobook:
return Icons.book;
case MediaType.podcast:
return Icons.podcasts;
case MediaType.podcastEpisode:
return Icons.podcasts;
default:
return Icons.audiotrack;
}
}
@override
Widget build(BuildContext context) {
_logger.startBuild('DiscoveryRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: _buildContent(contentHeight, colorScheme),
),
],
),
),
);
_logger.endBuild('DiscoveryRow:${widget.title}');
return result;
}
}
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+49 -5
View File
@@ -140,6 +140,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
static const double _collapsedBorderRadius = 16.0;
static double get _collapsedArtSize => MiniPlayerLayout.artSize;
static const double _bottomNavHeight = 56.0;
// Animation for hiding bottom nav offset on detail screens
late AnimationController _detailScreenController;
bool _lastIsOnDetailScreen = false;
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
// Pastel yellow for grouped players (matches PlayerCard.groupBorderColor)
@@ -318,6 +322,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Track lyrics scroll position for swipe-to-close (only allowed at top)
_lyricsScrollController.addListener(_onLyricsScroll);
// Animation for transitioning mini player when entering/leaving detail screens
_detailScreenController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_detailScreenController.addListener(_onDetailScreenAnimation);
// Slide animation for device switching - used for snap/spring animations
_slideController = AnimationController(
duration: const Duration(milliseconds: 250),
@@ -356,6 +367,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_loadVolumePrecisionModeSetting();
}
void _onDetailScreenAnimation() {
if (mounted) setState(() {});
}
Future<void> _loadVolumePrecisionModeSetting() async {
final enabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
@@ -415,6 +430,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// (Controllers remove listeners on dispose, but explicit removal is clearer)
_controller.removeListener(_notifyExpansionProgress);
_controller.removeListener(_recordAnimationFrame);
_detailScreenController.removeListener(_onDetailScreenAnimation);
_lyricsScrollController.removeListener(_onLyricsScroll);
_controller.dispose();
@@ -422,6 +438,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_sleepTimerPanelController.dispose();
_lyricsPanelController.dispose();
_slideController.dispose();
_detailScreenController.dispose();
_slideOffsetNotifier.dispose();
_positionSubscription?.cancel();
_queueItemsSubscription?.cancel();
@@ -640,7 +657,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_logger.log('📊 Subscribing to positionStream for progress bar');
_positionSubscription = maProvider.positionTracker.positionStream.listen((position) {
if (!mounted) return;
_logger.log('📊 Progress received position: ${position.inSeconds}s');
_logger.debug('📊 Progress received position: ${position.inSeconds}s');
_progressNotifier.value = position.inSeconds;
});
}
@@ -1794,9 +1811,23 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
Widget build(BuildContext context) {
// PERF: Use select() to only rebuild when adaptive theme setting changes
final adaptiveTheme = context.select<ThemeProvider, bool>((p) => p.adaptiveTheme);
// Watch detail screen state to animate mini player position
final isOnDetailScreen = context.select<ThemeProvider, bool>((p) => p.isOnDetailScreen);
// Get full themeProvider for methods that need adaptive color schemes
final themeProvider = context.read<ThemeProvider>();
// Animate bottom nav offset when entering/leaving detail screens
if (isOnDetailScreen != _lastIsOnDetailScreen) {
_lastIsOnDetailScreen = isOnDetailScreen;
if (isOnDetailScreen) {
_detailScreenController.duration = const Duration(milliseconds: 100);
_detailScreenController.forward();
} else {
_detailScreenController.duration = const Duration(milliseconds: 80);
_detailScreenController.reverse();
}
}
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final selectedPlayer = maProvider.selectedPlayer;
@@ -1925,7 +1956,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
? adaptiveScheme.onPrimaryContainer
: colorScheme.onPrimaryContainer;
final bottomNavSpace = _bottomNavHeight + bottomPadding;
final effectiveNavHeight = _bottomNavHeight * (1.0 - _detailScreenController.value);
final bottomNavSpace = effectiveNavHeight + bottomPadding;
final bottomOffset = bottomNavSpace + _collapsedMargin;
final width = screenSize.width - (_collapsedMargin * 2);
@@ -2074,9 +2106,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final textAlignment = Alignment.lerp(Alignment.centerLeft, Alignment.center, t)!;
final titleFontWeight = FontWeight.lerp(MiniPlayerLayout.primaryFontWeight, FontWeight.w600, t);
// Collapsed: position above bottom nav bar
// Collapsed: position above bottom nav bar (or at bottom edge on detail screens)
// Expanded: extend to very bottom (behind system nav bar) for seamless edge-to-edge
final bottomNavSpace = _bottomNavHeight + bottomPadding;
final effectiveNavHeight = _bottomNavHeight * (1.0 - _detailScreenController.value);
final bottomNavSpace = effectiveNavHeight + bottomPadding;
final collapsedBottomOffset = bottomNavSpace + _collapsedMargin;
final expandedBottomOffset = 0.0; // Extend behind system nav bar
final expandedHeight = screenSize.height; // Full screen height for edge-to-edge
@@ -2250,6 +2283,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Smoothly fade out horizontal slide offset (for device switching) as animation progresses
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
// Extend gesture hit area above mini player for easier swipe-down,
// especially on detail screens where the player sits closer to screen edge.
const _gestureExtension = 20.0;
return Positioned(
left: horizontalMargin,
right: horizontalMargin,
@@ -2475,7 +2512,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
});
}
},
child: Container(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Invisible gesture extension area above mini player
if (!isExpanded) const SizedBox(height: _gestureExtension),
Container(
// PERF Phase 4: Only show shadow when meaningfully visible (t < 0.5)
// This avoids BoxDecoration allocation for majority of animation frames
// Shadow fades out quickly during first half of expansion
@@ -3415,6 +3457,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
),
),
],
),
),
);
}
+11 -172
View File
@@ -3,51 +3,16 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../services/settings_service.dart';
import 'expandable_player.dart';
import 'player/mini_player_content.dart' show MiniPlayerLayout;
import 'player/player_reveal_overlay.dart';
/// Cached color with contrast adjustment
/// Avoids expensive HSL conversions during scroll
class _CachedNavColor {
Color? _sourceColor;
bool? _isDark;
Color? _adjustedColor;
Color getAdjustedColor(Color sourceColor, bool isDark) {
// Return cached value if inputs haven't changed
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
return _adjustedColor!;
}
// Compute new adjusted color
var navSelectedColor = sourceColor;
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
// Cache the result
_sourceColor = sourceColor;
_isDark = isDark;
_adjustedColor = navSelectedColor;
return navSelectedColor;
}
}
final _cachedNavColor = _CachedNavColor();
/// A global key to access the player state from anywhere in the app
final globalPlayerKey = GlobalKey<ExpandablePlayerState>();
@@ -197,6 +162,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
// State for player reveal overlay
bool _isRevealVisible = false;
// State for interactive hint mode (blur backdrop + wait for user action)
bool _isHintModeActive = false;
Timer? _hintBounceTimer;
@@ -530,138 +496,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
padding: const EdgeInsets.only(bottom: 0), // Content manages its own padding
child: widget.child,
),
// Global persistent bottom navigation bar - positioned at bottom
// Hide when: not connected OR showing welcome screen
// Always show when connected so users can access Settings/Library even without a player
if (isConnected && !shouldShowWelcomeBackdrop)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ListenableBuilder(
listenable: navigationProvider,
builder: (context, _) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDark = Theme.of(context).brightness == Brightness.dark;
// BottomNavigationBar is now in HomeScreen's Scaffold.bottomNavigationBar
// for proper Navigator/Overlay ancestry
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
builder: (context, expansionState, _) {
// Nav bar color logic - only use adaptive colors when:
// 1. Player is expanding/expanded, OR
// 2. On a detail screen (isOnDetailScreen)
// On home screen with collapsed player: always use default theme colors
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
// Nav bar background color
final Color navBgColor;
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
// Player is expanding - blend from surface to player's adaptive color
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
} else if (useAdaptiveColors) {
// On a detail screen - use adaptive surface color for nav bar
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
navBgColor = adaptiveBg ?? colorScheme.surface;
} else {
// Home screen with collapsed player - use default surface color
navBgColor = colorScheme.surface;
}
// Icon color - only use adaptive colors when appropriate
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
? themeProvider.adaptiveColors!.primary
: colorScheme.primary;
// Blend icon color with player's primary color during expansion
Color sourceColor = baseSourceColor;
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Fade out and slide down nav bar as player expands
// Use IgnorePointer when faded to prevent accidental taps
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
return IgnorePointer(
ignoring: navOpacity < 0.1,
child: Transform.translate(
offset: Offset(0, navSlideDown),
child: Opacity(
opacity: navOpacity,
child: Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
// Clear adaptive colors when switching tabs (detail screen colors shouldn't persist)
themeProvider.clearAdaptiveColors();
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Symbols.book_2, fill: 0),
activeIcon: const Icon(Symbols.book_2, fill: 1),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
),
),
),
);
},
);
},
);
},
),
),
// Blur backdrop for device selector (reveal mode) - static, no animation
if (_isRevealVisible && !_isHintModeActive)
Positioned.fill(
@@ -788,8 +625,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
PlayerRevealOverlay(
key: _revealKey,
onDismiss: _hidePlayerReveal,
// Use viewPadding to match BottomNavigationBar's height calculation
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
// Use viewPadding to match BottomNavigationBar's height calculation.
// On detail screens the nav bar is hidden so exclude its height.
miniPlayerBottom: (context.read<ThemeProvider>().isOnDetailScreen ? 0.0 : BottomSpacing.navBarHeight) + MediaQuery.of(context).viewPadding.bottom + 12,
miniPlayerHeight: 64,
showOnboardingHints: _isOnboardingReveal,
// Pass pending action parameters for "Play On" functionality
@@ -800,15 +638,16 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
// Global player overlay - renders ON TOP so cards slide behind it
// Use Selector instead of Consumer to avoid rebuilds during animation
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack})>(
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack, bool hasCachedPlayers})>(
selector: (_, provider) => (
isConnected: provider.isConnected,
hasPlayer: provider.selectedPlayer != null,
hasTrack: provider.currentTrack != null,
hasCachedPlayers: provider.hasCachedPlayers,
),
builder: (context, state, child) {
// Only show player if connected and has a selected player
if (!state.isConnected || !state.hasPlayer) {
// Show player if connected with a player, or if cached players exist (brief disconnect)
if ((!state.isConnected && !state.hasCachedPlayers) || !state.hasPlayer) {
// Reset expansion state to prevent stale values from causing
// invisible/non-interactive nav bar when connection restores.
// This fixes a bug where expanded player state persisted after
+1 -1
View File
@@ -108,7 +108,7 @@ class HiResBadge extends StatelessWidget {
),
);
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
// Only use Tooltip if an Overlay ancestor exists
final hasOverlay = Overlay.maybeOf(context) != null;
if (!hasOverlay) {
return content;
+11 -3
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../services/library_status_service.dart';
/// A widget that rebuilds when the library/favorite status of an item changes.
@@ -123,9 +124,16 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
void _onLibraryStatusChanged() {
// Trigger rebuild to pick up new status values
if (mounted) {
setState(() {});
}
// Defer setState to avoid calling it during build phase
if (!mounted) return;
// Use Future.microtask to defer setState until after the current build phase
// This prevents "setState during build" errors while avoiding Overlay issues
Future.microtask(() {
if (mounted) {
setState(() {});
}
});
}
/// Set library status optimistically and return previous value for rollback
+21 -17
View File
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show GlobalPlayerOverlay, BottomSpacing;
import '../theme/theme_provider.dart';
import '../l10n/app_localizations.dart';
import '../services/debug_logger.dart';
@@ -500,39 +501,42 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
final screenSize = MediaQuery.of(context).size;
final viewPadding = MediaQuery.of(context).viewPadding;
// Account for mini player and bottom nav bar
final bottomInset = BottomSpacing.withMiniPlayer + viewPadding.bottom;
// Account for mini player and bottom nav bar.
// On detail screens the nav bar hides and mini player drops to bottom edge.
final isOnDetailScreen = context.read<ThemeProvider>().isOnDetailScreen;
final bottomInset = isOnDetailScreen
? BottomSpacing.miniPlayerHeight + 22.0 + viewPadding.bottom
: BottomSpacing.withMiniPlayer + viewPadding.bottom;
// Position near the tap, but keep on screen
double left = widget.position.dx - menuWidth / 2;
double top = widget.position.dy;
// Keep menu on screen horizontally
left = left.clamp(8.0, screenSize.width - menuWidth - 8);
// Estimate menu height based on content
double estimatedHeight;
double estimatedHeight = 8.0; // vertical padding (4 top + 4 bottom)
if (widget.mediaType == ContextMenuMediaType.podcastEpisode) {
// Episode menu: 4 items + divider = ~200px
estimatedHeight = 200.0;
estimatedHeight += 4 * 46.0; // 4 menu items
} else {
estimatedHeight = widget.showTopRow ? 280.0 : 180.0;
// Add height for sort section (header + items)
if (widget.showTopRow) estimatedHeight += 60.0; // icon button row
estimatedHeight += 3 * 46.0; // Play on, Start radio, Start radio on
if (widget.onToggleSort != null) {
// Podcast has 4 sort options, others have 2
estimatedHeight += widget.mediaType == ContextMenuMediaType.podcast ? 170.0 : 110.0;
estimatedHeight += 1.0 + 32.0; // divider + header
final sortCount = widget.mediaType == ContextMenuMediaType.podcast ? 4 : 2;
estimatedHeight += sortCount * 42.0; // sort items
}
// Add height for view section (divider + header + 3 items)
if (widget.onCycleView != null) {
estimatedHeight += 140.0;
estimatedHeight += 1.0 + 32.0; // divider + header
estimatedHeight += 3 * 42.0; // view mode items
}
}
// If menu would go off bottom (accounting for mini player), position above the tap point
if (top + estimatedHeight > screenSize.height - bottomInset) {
top = widget.position.dy - estimatedHeight;
}
top = top.clamp(viewPadding.top + 8, screenSize.height - estimatedHeight - bottomInset);
// Keep menu below the tap point but above the mini player
final top = widget.position.dy.clamp(
viewPadding.top + 8,
screenSize.height - estimatedHeight - bottomInset,
);
return Stack(
children: [
-230
View File
@@ -1,230 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../models/recommendation_folder.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'playlist_card.dart';
import 'radio_station_card.dart';
class MixesRow extends StatefulWidget {
final String title;
final Future<List<RecommendationFolder>> Function() loadMixes;
final List<RecommendationFolder>? Function()? getCachedMixes;
final String? heroTagSuffix;
final double? rowHeight;
const MixesRow({
super.key,
required this.title,
required this.loadMixes,
this.getCachedMixes,
this.heroTagSuffix,
this.rowHeight,
});
@override
State<MixesRow> createState() => _MixesRowState();
}
class _MixesRowState extends State<MixesRow> with AutomaticKeepAliveClientMixin {
List<RecommendationFolder> _mixes = [];
bool _isLoading = true;
bool _hasLoaded = false;
static final _logger = DebugLogger();
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
final cached = widget.getCachedMixes?.call();
if (cached != null && cached.isNotEmpty) {
_mixes = cached;
_isLoading = false;
}
_loadMixes();
}
Future<void> _loadMixes() async {
if (_hasLoaded) return;
_hasLoaded = true;
try {
final freshMixes = await widget.loadMixes();
if (mounted && freshMixes.isNotEmpty) {
setState(() {
_mixes = freshMixes;
_isLoading = false;
});
_precacheMixImages(freshMixes);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
void _precacheMixImages(List<RecommendationFolder> mixes) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
final itemsToCache = <MediaItem>[];
for (final folder in mixes) {
itemsToCache.addAll(folder.items);
if (itemsToCache.length >= 10) break;
}
for (final mix in itemsToCache.take(10)) {
final imageUrl = maProvider.api?.getImageUrl(mix, size: 256);
if (imageUrl != null) {
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) => false);
}
}
}
Widget _buildShelfContent(List<MediaItem> items, double contentHeight) {
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: items.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final mix = items[index];
final key = ValueKey(mix.uri ?? mix.itemId);
Widget card;
if (mix is Playlist) {
card = PlaylistCard(
playlist: mix,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else if (mix.mediaType == MediaType.radio) {
card = RadioStationCard(
radioStation: mix,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else {
card = const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: key,
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: card,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('MixesRow:${widget.title}');
super.build(context);
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final baseRowHeight = widget.rowHeight ?? 237.0;
const titleHeight = 52.0;
final contentHeight = baseRowHeight - titleHeight;
final visibleFolders = _mixes.where((folder) {
final name = folder.name.trim().toLowerCase();
final id = folder.itemId.trim().toLowerCase();
final isRecentlyPlayed = name == 'recently played' || id.contains('recently_played');
return !isRecentlyPlayed;
}).toList();
final result = RepaintBoundary(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_mixes.isEmpty && _isLoading)
SizedBox(
height: baseRowHeight - titleHeight,
child: const Center(child: CircularProgressIndicator()),
)
else if (_mixes.isEmpty || visibleFolders.isEmpty)
SizedBox(
height: baseRowHeight - titleHeight,
child: Center(
child: Text(
S.of(context)!.noPlaylistMixesFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
),
)
else
...List<Widget>.generate(visibleFolders.length * 2 - 1, (index) {
if (index.isOdd) return const SizedBox(height: 8.0);
final folder = visibleFolders[index ~/ 2];
return SizedBox(
height: baseRowHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
folder.name,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: _buildShelfContent(folder.items, contentHeight),
),
],
),
);
}),
],
),
);
_logger.endBuild('MixesRow:${widget.title}');
return result;
}
}
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+18 -10
View File
@@ -100,12 +100,12 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
);
// Preload all player track info and extract colors
_preloadColorsForPlayers();
// Start reveal animation
// Start reveal animation first, then load colors after it settles
// (color extraction triggers setState which causes jitter during animation)
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward();
_revealController.forward().then((_) {
if (mounted) _preloadColorsForPlayers();
});
// Auto-refresh player data
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
@@ -266,15 +266,23 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
final hintColor = colorScheme.onSurface.withOpacity(0.7);
// Back gesture is handled at GlobalPlayerOverlay level
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final allPlayers = maProvider.availablePlayers;
// Use Selector to only rebuild when the player list actually changes,
// not on every provider notification (prevents jitter during animation)
return Selector<MusicAssistantProvider, ({List<dynamic> players, String? selectedId, int playerCount})>(
selector: (_, p) => (
players: p.availablePlayers,
selectedId: p.selectedPlayer?.playerId,
playerCount: p.availablePlayers.length,
),
shouldRebuild: (prev, next) => prev.playerCount != next.playerCount || prev.selectedId != next.selectedId,
builder: (context, data, child) {
final maProvider = context.read<MusicAssistantProvider>();
final allPlayers = data.players;
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
// Filter out the selected player - it's already shown in the mini player
final players = allPlayers
.where((p) => p.playerId != selectedPlayer?.playerId)
.where((p) => p.playerId != data.selectedId)
.toList();
// Default card colors - used when player doesn't have extracted colors
-2
View File
@@ -806,14 +806,12 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
),
onPressed: () => _handleTransferQueue(context),
padding: Spacing.paddingAll12,
tooltip: S.of(context)!.transferQueueToPlayer,
),
// Clear queue button
IconButton(
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: _handleClearQueue,
padding: Spacing.paddingAll12,
tooltip: S.of(context)!.clearQueue,
),
],
),
+2 -2
View File
@@ -107,7 +107,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px owner = ~44px
const textAreaHeight = 52.0;
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -152,7 +152,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
// Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -115,7 +115,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px author = ~44px
const textAreaHeight = 52.0;
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -160,7 +160,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
// Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -107,7 +107,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
// Card layout: circle image + name below (same as ArtistRow)
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 52.0;
const textAreaHeight = 44.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
@@ -148,7 +148,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
// Total row height includes title + content (same as ArtistRow)
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -179,7 +179,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
);
}
const textAreaHeight = 52.0;
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
@@ -231,7 +231,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 52.0;
const titleHeight = 44.0;
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -109,7 +109,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 52.0;
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12;
@@ -150,7 +150,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 224.0; // Default: 44 title + 180 content
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+8 -3
View File
@@ -72,6 +72,7 @@ class _VolumeControlState extends State<VolumeControl> {
setState(() {
_systemVolume = volume;
});
context.read<MusicAssistantProvider>().updateLocalPlayerVolume((volume * 100).round());
}
});
}
@@ -140,6 +141,7 @@ class _VolumeControlState extends State<VolumeControl> {
_systemVolume = newVolume / 100.0;
});
}
maProvider.updateLocalPlayerVolume(newVolume);
} catch (e) {
_logger.log('Volume: Error setting system volume - $e');
}
@@ -163,6 +165,7 @@ class _VolumeControlState extends State<VolumeControl> {
if (_isLocalPlayer) {
FlutterVolumeController.setVolume(volume);
maProvider.updateLocalPlayerVolume(volumeLevel);
} else {
maProvider.setVolume(playerId, volumeLevel);
}
@@ -171,8 +174,9 @@ class _VolumeControlState extends State<VolumeControl> {
@override
Widget build(BuildContext context) {
// Use select to only rebuild when selectedPlayer changes (not on every provider update)
// Use select to only rebuild when selectedPlayer or localPlayerVolume changes
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
final maLocalVolume = context.select<MusicAssistantProvider, int>((p) => p.localPlayerVolume);
if (player == null) {
return const SizedBox.shrink();
@@ -182,9 +186,9 @@ class _VolumeControlState extends State<VolumeControl> {
// Use pending volume during drag or button tap to prevent stale state issues
final currentVolume = (_isDragging || _showButtonIndicator)
? (_pendingVolume ?? (_isLocalPlayer ? (_systemVolume ?? 0.5) : player.volume.toDouble() / 100.0))
? (_pendingVolume ?? (_isLocalPlayer ? maLocalVolume / 100.0 : player.volume.toDouble() / 100.0))
: _isLocalPlayer
? (_systemVolume ?? 0.5)
? maLocalVolume / 100.0
: player.volume.toDouble() / 100.0;
// Use accent color if provided, otherwise fall back to theme primary
@@ -298,6 +302,7 @@ class _VolumeControlState extends State<VolumeControl> {
if (_isLocalPlayer) {
FlutterVolumeController.setVolume(finalVolume);
_systemVolume = finalVolume;
context.read<MusicAssistantProvider>().updateLocalPlayerVolume(volumeLevel);
} else {
context.read<MusicAssistantProvider>().setVolume(player.playerId, volumeLevel);
}
+260
View File
@@ -0,0 +1,260 @@
# Pull Request: Group Volume Management, Hardware Volume Integration, and Bugfixes
## Summary
This PR represents a testing and debugging cycle following v3.0.2. Initial work on sync group volume management uncovered multiple UI bugs that required fixes. The final result addresses:
1. **Fix: Group Player Volume Management** - Two distinct volume control issues for Music Assistant sync groups
2. **Feature: Hardware Volume Button Integration** - Phone volume buttons now control Music Assistant player volume from lockscreen / shade widget / app minimised
3. **UI Bug Fixes** - Five UI issues discovered during sync group testing, which had to be fixed to get the sync groups to work without crashes
4. **Fix: Cold Start Homepage Race Condition** - Intermittent empty homepage on app cold start - surfaced once sync group volume calculations were introduced.
5. **Code Quality: Cleanup and Optimization** - Removes dead code and eliminates duplicate logic - mine, not yours, I tried to not to touch your code as much as possible
All changes have been tested and verified with `dart analyze` showing no new issues.
---
## Group Volume Management Fixes
### 1. Group Player Volume Management
**Issue:** Volume control for Music Assistant sync groups had two distinct problems depending on how the group was created:
1. **Server-side groups**: Volume slider would snap back to 0 immediately after release
2. **In-app created groups**: Adjusting the leader's volume only changed that one player, not all members proportionally
#### Part A: Server-Side Groups (Null Volume Snap-back)
**Root Cause:** The Music Assistant API returns `volume_level: null` for certain group players (pre-configured speaker groups with `provider == 'player_group'` and some server-side sync groups). The app had no logic to handle this case, so the null volume was interpreted as 0.
**Solution:** Implemented `GroupVolumeManager` service with dual-tier volume protection:
- **Tier 1 (GroupVolumeManager):** Computes effective volume from group members (detects groups, resolves nested, averages volumes, caches pending for 30s, confirms with ±3 tolerance).
- **Tier 2 (Provider-level):** Optimistic update protection (caches for 8s/30s, prevents stale overwrites).
#### Part B: In-App Created Groups (Proportional Volume Propagation)
**Root Cause:** For in-app groups, leader has non-null volume, so `setVolume()` only affects leader, not members.
**Expected behavior:** Leader adjustment changes all members proportionally; individual adjustments affect only that member.
**Solution:** In `setVolume()`, for group leaders with non-null volume:
1. Calculate proportional: `memberNewVol = (memberCurrentVol * newVol / leaderCurrentVol).round()` (handle zero case with absolute).
2. Cache pending, update optimistically, fire parallel API calls.
**Files Changed:**
- `lib/services/group_volume_manager.dart` (NEW - 162 lines)
- `lib/providers/music_assistant_provider.dart` (added manager, helper, updated events/state/volume methods)
---
## Features
### 2. Hardware Volume Button Integration (Convenience Feature)
**Feature:** Phone hardware volume buttons (including lockscreen) control MA player volume, with system HUD mirroring.
**Implementation:**
- **Android Native:** `MainActivity.kt` (~120 lines) - VolumeContentObserver, MethodChannel commands, lifecycle.
- **Flutter Service:** `hardware_volume_service.dart` (~65 lines) - streams, observer methods, sync.
- **Integration:** `main.dart` (~35 lines) - adjust/set/update methods, subscription.
**Why This Matters:** Seamless remote control from lockscreen/tray/buttons.
---
## UI Bug Fixes
During sync group testing, fixed these UI bugs:
### 3A. Zone Mismatch Warning
**File:** `lib/main.dart`
**Issue:** Console warnings during init.
**Root Cause:** Init code outside `runZonedGuarded()`.
**Fix:** Move init inside `runZonedGuarded()`.
---
### 3B. BottomNavigationBar "No Overlay" Crash
**Files:** `lib/screens/home_screen.dart`, `lib/widgets/global_player_overlay.dart`
**Issue:** Crash on nav interaction.
**Root Cause:** Nav in Stack without Overlay.
**Fix:** Move nav to Scaffold.bottomNavigationBar, add extendBody: true.
---
### 3C. setState During Build Warning (Artist/Album Cards)
**Files:** `lib/widgets/artist_card.dart`, `lib/widgets/album_card.dart`
**Issue:** Warnings causing corruption.
**Root Cause:** Service calls in initState trigger setState during build.
**Fix:** Defer with addPostFrameCallback, add mounted guard.
---
### 3D. context.select Inside SliverList Crash
**File:** `lib/screens/new_library_screen.dart`
**Issue:** Crash on library tabs.
**Root Cause:** context.select in sliver context.
**Fix:** Use Consumer wrappers for tabs.
---
### 3E. QueuePanel Tooltip "No Overlay" Error
**File:** `lib/widgets/player/queue_panel.dart`
**Issue:** Crash on long-press buttons.
**Root Cause:** Tooltips require Overlay, but in Stack.
**Fix:** Remove tooltip properties.
---
### 3F. Settings Back Button Crash
**File:** `lib/screens/settings_screen.dart`
**Issue:** Back button crashes to black screen.
**Root Cause:** Navigator.pop pops home screen.
**Fix:** Use showPlayer + setSelectedIndex(0).
---
## Cold Start Homepage Race Condition Fix
### 4. Cold Start Homepage Empty/No Refresh
**Issue:** Intermittent empty homepage on cold start.
**Root Cause:** isConnected true before auth complete, leading to failed fetches.
**Solution:** Delay broadcasting 'connected' state until auth if required (`music_assistant_provider.dart:1058-1101`).
---
## Technical Implementation Details
### GroupVolumeManager Architecture
The `GroupVolumeManager` is a stateful service that handles **server-side groups with null volume**:
1. **Detects group players:**
```dart
bool isGroupPlayer(Player player) {
// Pre-configured MA speaker groups
if (player.provider == 'player_group') return true;
// Dynamic sync groups: have multiple members but MA returns null volume
if (player.groupMembers != null &&
player.groupMembers!.length > 1 &&
player.volumeLevel == null) {
return true;
}
return false;
}
```
2. **Computes effective volume:**
- Recursively resolves nested groups (max depth 3)
- Averages volumes across all leaf members
- Skips self-references in `groupMembers`
- Returns `null` if no members have valid volumes
3. **Caches pending volumes:**
- When user sets volume: stored with timestamp
- Returns cached value if within 30s timeout
- Clears cache when member average converges (±3 tolerance)
- Falls back to computed average after timeout
4. **Integrates with provider:**
- Called from `_applyVolumeProtection()` in provider
- Receives fresh `allPlayers` list for accurate member volumes
- Injects effective volume into `_selectedPlayer` via `copyWith()`
### Proportional Volume Propagation (In-App Groups)
For **in-app created groups with non-null leader volume**, proportional propagation works as follows:
1. **Detection:** `setVolume()` checks if `player.isGroupLeader && !isGroupPlayer(player)`
- Leader has `groupMembers` with 2+ entries
- Leader has its own non-null `volumeLevel`
- NOT detected as a group player (GroupVolumeManager handles those)
2. **Calculation:** For each member in `groupMembers` (excluding leader itself):
```dart
if (leaderCurrentVol > 0) {
memberNewVol = (memberVol * newVol / leaderCurrentVol).round().clamp(0, 100)
} else {
memberNewVol = newVol // Can't ratio from zero
}
```
3. **Execution:**
- Cache `_pendingVolumes[memberId]` and `_pendingVolumeTimestamps[memberId]`
- Update `_availablePlayers[idx]` optimistically
- Fire `_api?.setVolume(memberId, memberNewVol)` without awaiting
- Continue to send leader's own `volume_set` command
4. **Result:** All member volumes scale proportionally with the leader, preserving relative balance.
### Connection State Flow (After Fix)
```
WebSocket connects → MAConnectionState.connected
↓
Auth required?
↙ ↘
YES NO
↓ ↓
Don't notify Set state + notify
Try auth ↓
↓ Initialize
Success?
↙ ↘
YES NO
↓ ↓
auth error state
event + notify
↓
Set state
+ notify
↓
Initialize
```
**Key:** Listeners only see `isConnected = true` after auth completes (or immediately if no auth required).
## Files Modified
android/.../MainActivity.kt | 120 + (NEW - ContentObserver)
lib/main.dart | 207 ±
lib/providers/music_assistant_provider.dart | 165 ±
lib/screens/home_screen.dart | 172 ± (BottomNav moved here)
lib/screens/new_library_screen.dart | 70 ± (Consumer wrappers)
lib/screens/settings_screen.dart | 9 ± (back button fix)
lib/services/group_volume_manager.dart | 162 + (NEW)
lib/services/hardware_volume_service.dart | 65 + (NEW)
lib/widgets/album_card.dart | 23 ± (postFrameCallback)
lib/widgets/artist_card.dart | 23 ± (postFrameCallback)
lib/widgets/global_player_overlay.dart | 171 - (BottomNav moved out)
lib/widgets/player/queue_panel.dart | 2 - (tooltip removed)
**Total:** ~650 added, ~300 removed/refactored.
+35 -3
View File
@@ -351,7 +351,7 @@ packages:
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: transitive
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
@@ -854,7 +854,7 @@ packages:
source: hosted
version: "4.1.0"
rxdart:
dependency: transitive
dependency: "direct main"
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
@@ -1106,6 +1106,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
@@ -1114,6 +1138,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
@@ -1252,4 +1284,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.24.0"
flutter: ">=3.35.0"
+3 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 3.0.2+54
version: 3.0.5+57
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -27,6 +27,7 @@ dependencies:
web_socket_channel: ^2.4.0
http: ^1.1.0
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.3.1
# Storage
shared_preferences: ^2.2.2
@@ -49,6 +50,7 @@ dependencies:
audio_service: ^0.18.12
audio_session: ^0.1.18
flutter_volume_controller: ^1.3.2
rxdart: ^0.28.0
# Raw PCM Audio Playback (for Sendspin streaming)
flutter_pcm_sound: ^3.3.3