Compare commits

...
47 Commits
Author SHA1 Message Date
Chris 8686edd8d7 On fix/aa-images-and-flickering: MA beta 14 player merging WIP 2026-05-05 06:03:45 +01:00
Chris 8e5d4ab002 index on fix/aa-images-and-flickering: 40a2e2f Fix AA shuffle/repeat buttons and artist images 2026-05-05 06:03:45 +01:00
ChrisandClaude Opus 4.6 40a2e2f43a Fix AA shuffle/repeat buttons and artist images
Build Android APK / Build APK (pull_request) Failing after 23m4s
Shuffle/repeat buttons showed disabled after starting playback because
_refreshQueueAfterDelay only updated queue items without syncing the
shuffle/repeat state. Now syncs both flags and refreshes playback state.

Artist images failed in AA because _autoBuildArtistList used the basic
getImageUrl which returns null for artists without MA metadata images.
Now uses getArtistImageUrlWithFallback which tries external sources.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 08:27:14 +00:00
ChrisandClaude Opus 4.6 e15cfb545e Smooth scroll-to-hide for library filter bar and search bar
Replace boolean toggle with continuous ValueNotifier<double> so bars
slide 1:1 with finger movement instead of snapping. Use
ValueListenableBuilder to avoid full-screen rebuilds on every scroll
frame, and Align(heightFactor) for uniform top-clip instead of
SizedBox squishing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 06:47:18 +00:00
ChrisandClaude Opus 4.6 76d0f0b80e Sync shuffle/repeat state from queue when AA connects
The local _shuffleOn and _repeatMode flags were initialized to off,
so AA buttons always showed disabled on connect even when shuffle/repeat
were actually enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 06:14:36 +00:00
ChrisandClaude Opus 4.6 a3eb3db8a2 Fix 4 regressions: BT/AA pause, AA buttons, artist cache, AA disconnect
- Make onPlay/onPause callbacks non-toggle (resumePlayer/pausePlayer
  instead of playPauseSelectedPlayer) so AA buttons work correctly
- Fix interruption guard to treat builtin-active as "was playing" for
  PCM/Sendspin mode where playbackState can be stale
- Keep media session alive during transient idle when PCM is playing
- Only cache non-empty artist album results to prevent stale 0 counts
- Only pause builtin player on AA disconnect (not remote speakers)
- Auto-select builtin player on AA connect broadcast

Fixes #25

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:54:47 +00:00
ChrisandClaude Opus 4.6 1877205034 Add Start Radio button to artist albums page in Android Auto
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:12:08 +00:00
ChrisandClaude Opus 4.6 bb46a2dee1 Remove noisy periodic logging that filled the 1000-entry bug report buffer
Position tracker, queue persistence, notification timer, and message
receipt logs fired every few seconds, pushing out meaningful events
(AA lifecycle, playback state changes) within minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:51:03 +00:00
ChrisandClaude Opus 4.6 2c07134404 Fix volume slider grab area by adding opaque hit test behavior
Without HitTestBehavior.opaque, the GestureDetector only registered
hits on the 20px painted track area instead of the full 48px touch target.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:12:03 +00:00
ChrisandClaude Opus 4.6 ea99b2bb11 Fix AA cold-start: async list builders and cache invalidation
List builders for artists, albums, and playlists now await
SyncService.loadFromCache() when data is empty (matching the existing
audiobook pattern). getArtistAlbumsWithCache also falls back to
SyncService when _albums is empty. After library loads (both from cache
and background sync), AA browse children are invalidated so stale
empty results get replaced.

Fixes #25

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:50:11 +00:00
ChrisandClaude Opus 4.6 9cd27553c1 Fix AA cold-start caching empty artist albums
Don't cache empty artist album results when the library hasn't loaded
yet. On cold start, SyncService may not have finished populating the
album list, causing getArtistAlbumsWithCache to return and cache 0
results permanently until the cache expires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 06:54:43 +00:00
ChrisandClaude Opus 4.6 0db9bb3df7 Fix AA detection: trigger from any getChildren/playFromMediaId, not just root
AA caches the browse tree after first session, so getChildren for the
browsable root is never called on subsequent connects. This meant
_isAndroidAutoConnected was never set, breaking both disconnect detection
(display listener ignored) and playback button refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:54:56 +00:00
ChrisandClaude Opus 4.6 0a88d147ed Fix AA disconnect: detect via display removal, prevent auto-resume
Android Auto on projection-mode devices (com.google.android.projection.gearhead)
doesn't trigger ACTION_EXIT_CAR_MODE, so the disconnect was never detected.

- Add DisplayManager.DisplayListener to detect AA disconnect when virtual
  displays are removed (projection mode)
- Add bidirectional method channel so Dart notifies native of AA connection
- Add onAADisconnected callback that force-pauses (not toggles) the player
- Suppress Sendspin stream/start for 2s after disconnect to handle the race
  where the server sends audio before processing our pause command
- Guard interruption begin handler to only pause/toggle when actually playing,
  preventing the interrupted-while-paused -> accidental resume bug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:56:13 +00:00
ChrisandClaude Opus 4.6 c3f6710dd1 Hide switch player in AA, fix resume on disconnect, rename Smart Shuffle
- Detect Android Auto via car mode broadcasts and getChildren; hide
  switch player button from controls while AA is connected
- Suppress audio auto-resume after becoming noisy / AA disconnect to
  prevent music playing from phone speaker when car turns off
- Rename "Smart Shuffle" to "Start Radio" with radio icon

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:03:16 +00:00
ChrisandClaude Opus 4.6 68e3b74d5d Add Smart Shuffle to Android Auto and radio fallback to shuffled playback
Radio calls now fall back gracefully when the provider doesn't support radio:
playRadio falls back to playing the single track, playArtistRadio falls back
to shuffled artist tracks via search. Direct API calls in context menu and
library screen routed through provider to pick up the fallback. Android Auto
gets a "Smart Shuffle" item at the top of track lists (albums, playlists,
favorites) with a custom sparkle-shuffle icon that attempts radio then falls
back to shuffled playback. Also fixes builtin player selection to avoid
premature fallback before Sendspin registration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 06:18:08 +00:00
ChrisandClaude Opus 4.6 b3aa55a021 Fix podcast/audiobook metadata in notifications and AA, position tracking
- Add _trackDisplayArtist() helper to show podcast name, audiobook author,
  or radio station instead of "Unknown Artist" in all notification/AA contexts
- Auto-restore audiobook context from cached data when URI matches
- Set podcast name context when playing episodes from AA browse tree
- Encode podcast ID in episode media IDs for context lookup
- Fix Sendspin pause position using server-synced position tracker instead
  of PCM elapsed time (which resets to 0 each stream)
- Fix Sendspin resume position to use position tracker instead of Duration.zero
- Add 10s seek-back on audiobook resume to compensate for Sendspin buffering
- Populate AA queue for podcast and radio playback via _refreshQueueAfterDelay
- Show podcast name as artist in AA episode browse list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:53:07 +00:00
ChrisandClaude Opus 4.6 823b95ec1a Fix builtin player notification conflicts and player selection fallback
Use updateLocalModeNotification instead of setRemotePlaybackState for
Sendspin/builtin players to avoid conflicting mediaItem.add() calls that
cause artwork flickering. Add fallback to builtin player when last
selected player is unavailable. Extract shared _controls getter, use
MediaControl.custom for switch player, and only claim audio focus on
play transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 06:45:06 +00:00
ChrisandClaude Opus 4.6 59d48deaf7 Android Auto audit: artwork ContentProvider, queue, search, polish
- Add ArtworkContentProvider to serve artwork via content:// URIs
  (fixes broken images on many AA head units that reject HTTP URLs)
- Wrap all artUri sites in handler and provider with content:// URIs
- Populate AA play queue after playTracks for queue view support
- Wire updateQueueIndex from provider's _updatePlayerState
- Add error state communication to AA on playback failures
- Rewrite search() to return grouped results (artists, albums, tracks)
- Add playFromSearch() for voice command support ("Play X on Ensemble")
- Add search race condition guard
- Declare SEARCH_SUPPORTED in browsable root extras for search button
- Add invalidateAutoChildren for library add/delete events
- Add cache size limit (50 entries) with LRU eviction for track cache
- Clean up track cache and children subjects in dispose()
- Add audiobook completion status extras (progress bars in AA)
- Add subtitle link extras on track items (tappable artist)
- Reset idle timer on AA browse activity
- Fix PCM player auto-recovery triggering on user-initiated pause

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:16:21 +00:00
ChrisandClaude Opus 4.6 753f5161fb Fix AA reconnect race condition and audio focus handling
Make checkAndReconnect idempotent: concurrent callers now join the
existing reconnect attempt instead of each creating a new WebSocket
connection. This prevents the cascading "API disposed" errors seen
when Android Auto browses multiple categories simultaneously.

Activate audio session when Sendspin/remote playback starts so Android
delivers audio focus interruption events. Ensemble now pauses when
another app (Spotify, YouTube, etc.) takes audio focus, and resumes
when focus returns.

Remove switch-player button from Android Auto controls (not useful
in car context).

Fixes #25

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:07:17 +00:00
ChrisandClaude Opus 4.6 e4cdb40fde Fix home screen gradient obscuring third row text
Only show the bottom fade gradient when 4+ rows are enabled, since
with ≤3 rows the content doesn't scroll behind the mini player. Reduce
gradient height to mini player height only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:11:22 +00:00
ChrisandClaude Opus 4.6 ad5ecf59b0 Fix Android Auto images and player background flickering
Fix imageproxy URLs using wrong port: Dart's Uri.hasPort returns false
for default ports (80/443) even when explicitly specified, causing
fallback to port 8095. Use raw string check instead.

Fix player background flash: use theme surface color as fallback instead
of hardcoded black, and add race guard to color extraction to prevent
stale results on rapid track changes.

Fixes #25, fixes #73

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 10:05:31 +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
57 changed files with 4580 additions and 663 deletions
+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
+20 -1
View File
@@ -1,7 +1,8 @@
<div align="center">
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
[![GitHub release](https://img.shields.io/github/v/release/CollotsSpot/Ensemble?style=for-the-badge&logo=github)](https://github.com/CollotsSpot/Ensemble/releases)
[![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)
@@ -85,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
@@ -140,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).
+10
View File
@@ -54,6 +54,16 @@
</intent-filter>
</receiver>
<provider
android:name=".ArtworkContentProvider"
android:authorities="com.collotsspot.ensemble.artwork"
android:exported="true"
android:grantUriPermissions="true" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -0,0 +1,84 @@
package com.collotsspot.ensemble
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Base64
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
/**
* ContentProvider that serves artwork images to Android Auto.
*
* Android Auto only supports content:// and android.resource:// URIs for artwork.
* This provider accepts a base64url-encoded HTTP URL in the path, downloads the
* image (with disk caching), and returns a ParcelFileDescriptor.
*
* URI format: content://com.collotsspot.ensemble.artwork/{base64url_encoded_http_url}
*/
class ArtworkContentProvider : ContentProvider() {
companion object {
private const val CONNECT_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 10000
}
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
val encoded = uri.lastPathSegment ?: return null
val httpUrl: String
try {
httpUrl = String(Base64.decode(encoded, Base64.URL_SAFE or Base64.NO_WRAP))
} catch (e: Exception) {
return null
}
val cacheDir = File(context?.cacheDir, "artwork")
if (!cacheDir.exists()) cacheDir.mkdirs()
val hash = md5(httpUrl)
val cacheFile = File(cacheDir, hash)
if (!cacheFile.exists()) {
try {
val connection = URL(httpUrl).openConnection() as HttpURLConnection
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
connection.instanceFollowRedirects = true
try {
connection.inputStream.use { input ->
cacheFile.outputStream().use { output ->
input.copyTo(output)
}
}
} finally {
connection.disconnect()
}
} catch (e: Exception) {
cacheFile.delete()
return null
}
}
return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?): Cursor? = null
override fun getType(uri: Uri): String = "image/*"
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?,
selectionArgs: Array<String>?): Int = 0
private fun md5(input: String): String {
val digest = MessageDigest.getInstance("MD5")
val bytes = digest.digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -1,12 +1,19 @@
package com.collotsspot.ensemble
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.ContentObserver
import android.hardware.display.DisplayManager
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.app.UiModeManager
import android.util.Log
import android.view.Display
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -17,7 +24,10 @@ import io.flutter.plugin.common.MethodChannel
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
private val AA_CHANNEL = "com.collotsspot.ensemble/android_auto"
private var methodChannel: MethodChannel? = null
private var aaChannel: MethodChannel? = null
private var carModeReceiver: BroadcastReceiver? = null
private var isListening = false
// Volume observer for lockscreen volume changes
@@ -30,6 +40,10 @@ class MainActivity: AudioServiceActivity() {
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
private var ignoringVolumeChange = false
// Android Auto projection tracking via display listener
private var isAATracked = false
private var displayListener: DisplayManager.DisplayListener? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
@@ -74,6 +88,65 @@ class MainActivity: AudioServiceActivity() {
}
}
}
// Android Auto / car mode detection
aaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AA_CHANNEL)
// Receive AA connection signal from Dart side
aaChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"notifyAAConnected" -> {
Log.d(TAG, "AA tracked via Dart notifyAAConnected")
isAATracked = true
result.success(null)
}
else -> result.notImplemented()
}
}
carModeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
UiModeManager.ACTION_ENTER_CAR_MODE -> {
Log.d(TAG, "Car mode ENTERED")
isAATracked = true
aaChannel?.invokeMethod("onAndroidAutoConnected", null)
}
UiModeManager.ACTION_EXIT_CAR_MODE -> {
Log.d(TAG, "Car mode EXITED")
aaChannel?.invokeMethod("onAndroidAutoDisconnected", null)
}
}
}
}
val carModeFilter = IntentFilter().apply {
addAction(UiModeManager.ACTION_ENTER_CAR_MODE)
addAction(UiModeManager.ACTION_EXIT_CAR_MODE)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
applicationContext.registerReceiver(carModeReceiver, carModeFilter, Context.RECEIVER_EXPORTED)
} else {
applicationContext.registerReceiver(carModeReceiver, carModeFilter)
}
// Detect AA disconnect via display removal (projection mode)
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayChanged(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {
if (!isAATracked) return
val hasExtraDisplay = dm.displays.any {
it.displayId != Display.DEFAULT_DISPLAY
}
if (!hasExtraDisplay) {
Log.d(TAG, "AA projection ended (all extra displays removed)")
isAATracked = false
aaChannel?.invokeMethod("onAndroidAutoDisconnected", null)
}
}
}
dm.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
}
/// Start observing system STREAM_MUSIC volume changes.
@@ -224,6 +297,13 @@ class MainActivity: AudioServiceActivity() {
override fun onDestroy() {
stopVolumeObserver()
carModeReceiver?.let {
try { applicationContext.unregisterReceiver(it) } catch (_: Exception) {}
}
displayListener?.let {
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
dm.unregisterDisplayListener(it)
}
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="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
</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,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="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
</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="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M4,22h16v2H4z"/>
</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="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M13,15V9h-1l-2,1v1h1.5v4z"/>
</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.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
</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="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M4,22h16v2H4z"/>
</vector>
@@ -0,0 +1,13 @@
<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">
<!-- Shuffle arrows -->
<path android:fillColor="#FFFFFF"
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
<!-- Sparkle -->
<path android:fillColor="#FFFFFF"
android:pathData="M19,1l-1.26,2.74L15,5l2.74,1.26L19,9l1.26,-2.74L23,5l-2.74,-1.26z"/>
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+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
// ============================================
+572
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,
@@ -4701,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;
@@ -4723,4 +5293,6 @@ class $AppDatabaseManager {
$$HomeRowCacheTableTableManager(_db, _db.homeRowCache);
$$SearchHistoryTableTableManager get searchHistory =>
$$SearchHistoryTableTableManager(_db, _db.searchHistory);
$$DetailTrackCacheTableTableManager get detailTrackCache =>
$$DetailTrackCacheTableTableManager(_db, _db.detailTrackCache);
}
+4
View File
@@ -70,6 +70,9 @@ void main() {
androidNotificationIcon: 'drawable/ic_notification',
androidShowNotificationBadge: false,
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
androidBrowsableRootExtras: {
'android.media.browse.SEARCH_SUPPORTED': true,
},
),
);
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
@@ -144,6 +147,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
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();
});
}
}
+1
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
+19 -5
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;
+45 -28
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;
+104 -68
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);
}
}
@@ -303,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),
),
),
),
),
@@ -515,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)
@@ -538,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(
@@ -549,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(
@@ -572,6 +605,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
);
}),
);
return _cachedCoverWidget!;
}
/// Builds an empty cell with either a solid color or a nested grid
@@ -689,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(
+4 -4
View File
@@ -148,11 +148,11 @@ class _HomeScreenState extends State<HomeScreen> {
/// 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
final isConnected = context.select<MusicAssistantProvider, bool>(
(p) => p.isConnected,
// Hide nav bar when not connected and no cached players
final showNavBar = context.select<MusicAssistantProvider, bool>(
(p) => p.isConnected || p.hasCachedPlayers,
);
if (!isConnected) return null;
if (!showNavBar) return null;
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
+27 -20
View File
@@ -9,7 +9,6 @@ import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/sync_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player/mini_player_content.dart' show MiniPlayerLayout;
import '../widgets/player_selector.dart';
import '../widgets/album_row.dart';
import '../widgets/artist_row.dart';
@@ -34,7 +33,6 @@ class NewHomeScreen extends StatefulWidget {
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
static final _logger = DebugLogger();
final ValueNotifier<int> _refreshSignal = ValueNotifier<int>(0);
double? _cachedRowHeight; // Cached to avoid size jumps when nav bar toggles
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
@@ -432,6 +430,28 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
),
),
),
// Bottom fade gradient to mask content behind mini player (4+ rows only)
if (_countEnabledRows() >= 4)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.miniPlayerHeight,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background.withOpacity(0.85),
],
),
),
),
),
),
],
);
},
@@ -482,9 +502,8 @@ 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
// With extendBody: true, constraints include the area behind the nav bar.
// Only subtract mini player space (not nav bar) since nav bar is Scaffold chrome.
final miniPlayerSpace = MiniPlayerLayout.height + 12.0 + 22.0; // 72 + 12 + 22 = 106
// Reserve space for the mini player overlay at the bottom.
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0;
final availableHeight = constraints.maxHeight - miniPlayerSpace;
// Account for margins between rows (2px each, 2 margins for 3 rows).
@@ -493,19 +512,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
const marginSize = 2.0;
final enabledRows = _countEnabledRows();
const marginsInView = 2 * marginSize; // always 2 margins for 3-row layout
final candidateRowHeight = (availableHeight - marginsInView) / 3;
// Cache rowHeight to prevent size jumps when the bottom nav bar toggles
// on connect/disconnect. The nav bar hides when disconnected, giving more
// vertical space and taller rows. When it reappears, rows shrink visibly.
// Fix: always ratchet down to the smaller (nav-bar-present) value, and
// only allow increases on large changes like screen rotation.
if (_cachedRowHeight == null || (candidateRowHeight - _cachedRowHeight!).abs() > 50) {
_cachedRowHeight = candidateRowHeight;
} else if (candidateRowHeight < _cachedRowHeight!) {
_cachedRowHeight = candidateRowHeight;
}
final rowHeight = _cachedRowHeight!;
final rowHeight = (availableHeight - marginsInView) / 3 + 2;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
@@ -527,8 +534,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
behavior: const _StretchScrollBehavior(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
// For ≤3 rows: no padding needed (rows + miniPlayerSpace = viewport)
// For 4+ rows: pad by miniPlayerSpace so last row scrolls above mini player
// 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,
+37 -38
View File
@@ -159,10 +159,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// PERF: Separate ValueNotifier for efficient UI updates (RestorableInt doesn't implement ValueListenable)
final ValueNotifier<int> _tabIndexNotifier = ValueNotifier<int>(0);
// Scroll-to-hide filter bars
bool _isFilterBarVisible = true;
double _lastScrollOffset = 0;
static const double _scrollThreshold = 10.0;
// Scroll-to-hide filter bars (0.0 = hidden, 1.0 = visible)
final ValueNotifier<double> _filterBarVisibility = ValueNotifier<double>(1.0);
bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging
// Options menu overlay - stored to close on navigation
@@ -859,17 +857,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
if (notification is ScrollUpdateNotification) {
final currentOffset = notification.metrics.pixels;
final delta = currentOffset - _lastScrollOffset;
if (delta.abs() > _scrollThreshold) {
final shouldShow = delta < 0 || currentOffset <= 0;
if (shouldShow != _isFilterBarVisible) {
setState(() {
_isFilterBarVisible = shouldShow;
});
}
_lastScrollOffset = currentOffset;
final delta = notification.scrollDelta ?? 0.0;
if (notification.metrics.pixels <= 0) {
_filterBarVisibility.value = 1.0;
} else {
_filterBarVisibility.value = (_filterBarVisibility.value - delta / _filterRowHeight).clamp(0.0, 1.0);
}
}
return false;
@@ -911,13 +903,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
void _onLetterScrollbarDragChanged(bool isDragging) {
setState(() {
_isLetterScrollbarDragging = isDragging;
// Show the filter bar when starting to drag
if (isDragging) {
_isFilterBarVisible = true;
}
});
_isLetterScrollbarDragging = isDragging;
// Show the filter bar when starting to drag
if (isDragging) {
_filterBarVisibility.value = 1.0;
}
}
void _animateToCategory(int index) {
@@ -942,6 +932,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_pageController.dispose();
_selectedTabIndex.dispose();
_tabIndexNotifier.dispose();
_filterBarVisibility.dispose();
_artistsScrollController.dispose();
_albumsScrollController.dispose();
_playlistsScrollController.dispose();
@@ -1449,7 +1440,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: Column(
children: [
// Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible)
_buildFilterRows(colorScheme, l10n, showLibraryTypeRow: _isFilterBarVisible),
ValueListenableBuilder<double>(
valueListenable: _filterBarVisibility,
builder: (context, visibility, _) {
return _buildFilterRows(colorScheme, l10n, barVisibility: visibility);
},
),
// Connecting banner when showing cached data
// Hide when we have cached players - UI is functional during background reconnect
if (!isConnected && syncService.hasCache && !context.read<MusicAssistantProvider>().hasCachedPlayers)
@@ -1546,23 +1542,25 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
// Consistent height for filter rows
static const double _filterRowHeight = 36.0;
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required bool showLibraryTypeRow}) {
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required double barVisibility}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Row 1: Media type chips (hides when scrolling)
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: showLibraryTypeRow ? _filterRowHeight : 0,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _buildMediaTypeChips(colorScheme, l10n),
ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: barVisibility,
child: SizedBox(
height: _filterRowHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _buildMediaTypeChips(colorScheme, l10n),
),
),
),
),
if (showLibraryTypeRow) const SizedBox(height: 12), // Space between rows
SizedBox(height: 12 * barVisibility), // Space between rows
// Row 2: Sub-category chips (left) + action buttons (right) - always visible
SizedBox(
height: _filterRowHeight,
@@ -4914,7 +4912,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
try {
// Start radio from this track
await maProvider.api?.playRadio(player.playerId, track);
await maProvider.playRadio(player.playerId, track);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -6079,8 +6077,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(
@@ -6110,7 +6109,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(
+9 -1
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 {
+61 -45
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;
});
}
}
+49 -29
View File
@@ -130,10 +130,8 @@ class SearchScreenState extends State<SearchScreen> {
// Advanced search scorer with fuzzy matching, stopword removal, etc.
final SearchScorer _searchScorer = SearchScorer();
// Scroll-to-hide search bar (vertical scroll only)
bool _isSearchBarVisible = true;
double _lastVerticalScrollOffset = 0;
static const double _scrollThreshold = 10.0;
// Scroll-to-hide search bar (0.0 = hidden, 1.0 = visible)
final ValueNotifier<double> _searchBarVisibility = ValueNotifier<double>(1.0);
// Filter chip position tracking for animated sliding highlight
final Map<String, GlobalKey> _filterKeys = {};
@@ -314,6 +312,7 @@ class SearchScreenState extends State<SearchScreen> {
void requestFocus() {
if (mounted) {
_searchBarVisibility.value = 1.0;
_focusNode.requestFocus();
}
}
@@ -332,6 +331,7 @@ class SearchScreenState extends State<SearchScreen> {
_pageController.dispose();
_filterScrollController.dispose();
_activeFilterNotifier.dispose();
_searchBarVisibility.dispose();
super.dispose();
}
@@ -343,17 +343,12 @@ class SearchScreenState extends State<SearchScreen> {
}
if (notification is ScrollUpdateNotification) {
final currentOffset = notification.metrics.pixels;
final delta = currentOffset - _lastVerticalScrollOffset;
if (delta.abs() > _scrollThreshold) {
final shouldShow = delta < 0 || currentOffset <= 0;
if (shouldShow != _isSearchBarVisible) {
setState(() {
_isSearchBarVisible = shouldShow;
});
}
_lastVerticalScrollOffset = currentOffset;
final delta = notification.scrollDelta ?? 0.0;
const maxHeight = kToolbarHeight + 16;
if (notification.metrics.pixels <= 0) {
_searchBarVisibility.value = 1.0;
} else {
_searchBarVisibility.value = (_searchBarVisibility.value - delta / maxHeight).clamp(0.0, 1.0);
}
}
return false;
@@ -530,16 +525,23 @@ class SearchScreenState extends State<SearchScreen> {
body: SafeArea(
child: Column(
children: [
// Animated search bar - hides on scroll down
AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
height: _isSearchBarVisible ? kToolbarHeight + 16 : 0,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
// Search bar - slides away with scroll
ValueListenableBuilder<double>(
valueListenable: _searchBarVisibility,
builder: (context, visibility, child) {
return ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: visibility,
child: child!,
),
);
},
child: SizedBox(
height: kToolbarHeight + 16,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
// Search field container
Expanded(
@@ -643,6 +645,7 @@ class SearchScreenState extends State<SearchScreen> {
],
),
),
),
),
// Main content
Expanded(
@@ -817,6 +820,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,
@@ -1196,6 +1200,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1238,6 +1243,7 @@ class SearchScreenState extends State<SearchScreen> {
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
onTap: () {
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1266,6 +1272,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1344,6 +1351,7 @@ class SearchScreenState extends State<SearchScreen> {
),
),
onTap: () {
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1372,6 +1380,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1440,7 +1449,10 @@ class SearchScreenState extends State<SearchScreen> {
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
)
: null,
onTap: () => _playTrack(track),
onTap: () {
removeFocus();
_playTrack(track);
},
),
),
);
@@ -1458,6 +1470,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1546,6 +1559,7 @@ class SearchScreenState extends State<SearchScreen> {
)
: null,
onTap: () {
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1576,6 +1590,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1651,7 +1666,7 @@ class SearchScreenState extends State<SearchScreen> {
)
: null,
onTap: () {
// Update adaptive colors before navigation
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1678,6 +1693,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1726,7 +1742,10 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => _playRadioStation(radio),
onTap: () {
removeFocus();
_playRadioStation(radio);
},
),
),
);
@@ -1744,6 +1763,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1800,7 +1820,7 @@ class SearchScreenState extends State<SearchScreen> {
overflow: TextOverflow.ellipsis,
),
onTap: () {
// Update adaptive colors before navigation
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
File diff suppressed because it is too large Load Diff
+200
View File
@@ -55,6 +55,9 @@ 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
// ============================================================================
@@ -319,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
@@ -353,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
@@ -484,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
// ============================================================================
@@ -500,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
@@ -644,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
// ============================================
+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)
+53 -62
View File
@@ -68,6 +68,14 @@ class MusicAssistantAPI {
MusicAssistantAPI(this.serverUrl, this.authManager);
/// Check if a URL string has an explicit port (e.g., ":80", ":8095").
/// Dart's Uri.hasPort returns false for default ports (80/443) even when
/// explicitly specified, so we check the raw string instead.
bool _hasExplicitPort(String url) {
final withoutScheme = url.replaceFirst(RegExp(r'^https?://'), '');
return RegExp(r'^[^/:]+:\d+').hasMatch(withoutScheme);
}
// Guard to prevent multiple simultaneous connection attempts
Completer<void>? _connectionInProgress;
bool _isDisposed = false;
@@ -145,7 +153,7 @@ class MusicAssistantAPI {
queryParameters: {'client_id': clientId},
);
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
} else if (uri.hasPort) {
} else if (_hasExplicitPort(serverUrl)) {
// Port is already specified in URL, keep it
finalUri = Uri(
scheme: uri.scheme,
@@ -251,7 +259,6 @@ class MusicAssistantAPI {
void _handleMessage(dynamic message) {
try {
final data = jsonDecode(message as String) as Map<String, dynamic>;
_logger.log('Received message: ${data.keys}');
// Check for server info message (first message on connect)
if (data.containsKey('server_version')) {
@@ -1621,36 +1628,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
@@ -1711,35 +1710,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
@@ -1996,7 +1988,6 @@ class MusicAssistantAPI {
currentIndex = queueResult['current_index'] as int?;
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
repeatMode = queueResult['repeat_mode'] as String?;
_logger.log('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
final currentItemName = currentItemData?['name'] as String?;
@@ -2032,7 +2023,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;
}
@@ -2499,7 +2490,7 @@ class MusicAssistantAPI {
port: _cachedCustomPort,
path: '/flow/$playerId/$streamId.$extension',
);
} else if (uri.hasPort) {
} else if (_hasExplicitPort(serverUrl)) {
// Use port from URL
finalUri = Uri(
scheme: uri.scheme,
@@ -3211,7 +3202,7 @@ class MusicAssistantAPI {
if (_cachedCustomPort != null) {
// Use cached custom port from settings
baseUrl = '${uriObj.scheme}://${uriObj.host}:$_cachedCustomPort';
} else if (!uriObj.hasPort) {
} else if (!_hasExplicitPort(serverUrl)) {
if (useSecure) {
// For HTTPS, don't add port 443 (it's the default)
baseUrl = '${uriObj.scheme}://${uriObj.host}';
@@ -3318,7 +3309,7 @@ class MusicAssistantAPI {
if (_cachedCustomPort != null) {
// Use cached custom port from settings
baseUrl = '${uri.scheme}://${uri.host}:$_cachedCustomPort';
} else if (!uri.hasPort) {
} else if (!_hasExplicitPort(serverUrl)) {
if (useSecure) {
// For HTTPS, don't add port 443 (it's the default)
baseUrl = '${uri.scheme}://${uri.host}';
+18 -2
View File
@@ -69,6 +69,7 @@ class PcmAudioPlayer {
bool _isFeeding = false;
bool _isStarted = false;
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
bool _userPaused = false; // True when pause is user-initiated (not transitional)
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
// Error callback for operation failures
@@ -88,6 +89,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 +117,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;
@@ -227,7 +236,9 @@ class PcmAudioPlayer {
if (_shouldBlockFeeding) {
// If we're paused and audio is arriving, this is likely a new stream
// starting before the stream/start message. Queue for auto-recovery.
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
// BUT: don't auto-recover if the user explicitly paused — the arriving
// audio is just the tail end of the previous stream.
if (_state == PcmPlayerState.paused && !_isAutoRecovering && !_userPaused) {
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
_isAutoRecovering = true;
_addToBuffer(audioData);
@@ -360,7 +371,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) {
@@ -481,6 +495,7 @@ class PcmAudioPlayer {
if (_state == PcmPlayerState.paused) {
// Resume from pause - need to re-initialize since we released on pause
_userPaused = false;
_logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s');
_state = PcmPlayerState.resuming;
@@ -536,6 +551,7 @@ class PcmAudioPlayer {
}
_logger.log('PcmAudioPlayer: Pause requested');
_userPaused = true;
// Set pausing state FIRST to stop feed loop from starting new feeds
_state = PcmPlayerState.pausing;
+1 -22
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'debug_logger.dart';
/// Single source of truth for playback position tracking.
///
@@ -13,8 +12,6 @@ import 'debug_logger.dart';
/// All consumers (UI, notification) should listen to [positionStream]
/// instead of calculating position themselves.
class PositionTracker {
final DebugLogger _logger = DebugLogger();
// Current state
String? _playerId;
bool _isPlaying = false;
@@ -100,7 +97,6 @@ class PositionTracker {
Duration? duration,
double? serverTimestamp,
}) {
_logger.log('⏱️ 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 +175,8 @@ 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)');
_anchorPosition = newAnchorPos;
_anchorTime = DateTime.now();
} else if (positionDiff > 2) {
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
}
_isPlaying = isPlaying;
@@ -227,7 +220,6 @@ class PositionTracker {
/// Clear tracker state (e.g., when disconnecting)
void clear() {
_logger.log('⏱️ PositionTracker.clear() called');
_stopInterpolationTimer();
_playerId = null;
_isPlaying = false;
@@ -237,20 +229,9 @@ class PositionTracker {
}
void _startInterpolationTimer() {
if (_interpolationTimer != null) {
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
return;
}
if (_interpolationTimer != null) return;
_logger.log('⏱️ 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');
}
}
_emitPosition();
});
@@ -259,7 +240,6 @@ class PositionTracker {
}
void _stopInterpolationTimer() {
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_interpolationTimer?.cancel();
_interpolationTimer = null;
}
@@ -271,7 +251,6 @@ 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)');
_positionController.add(pos);
}
}
+20 -4
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',
@@ -321,8 +321,6 @@ class SendspinService {
final data = jsonDecode(message) as Map<String, dynamic>;
final type = data['type'] as String?;
_logger.log('Sendspin: Received message type: $type');
switch (type) {
case 'server/hello':
// Server acknowledged our client/hello - we're registered!
@@ -427,7 +425,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 +539,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');
+63 -8
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) {
@@ -180,8 +193,9 @@ class SyncService with ChangeNotifier {
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;
}
@@ -207,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
@@ -251,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]),
@@ -263,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) {
@@ -325,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) {
@@ -372,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;
@@ -474,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 {
@@ -489,6 +539,7 @@ class SyncService with ChangeNotifier {
_cachedArtists = [];
_cachedAudiobooks = [];
_cachedPlaylists = [];
_cachedPodcasts = [];
_albumSourceProviders = {};
_artistSourceProviders = {};
_audiobookSourceProviders = {};
@@ -574,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
+2
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';
@@ -377,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(),
+2 -1
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';
@@ -101,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
+3 -1
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
@@ -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(
+8 -3
View File
@@ -81,6 +81,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
String? _lastImageUrl;
int _colorExtractionId = 0;
// Queue state
PlayerQueue? _queue;
@@ -657,7 +658,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;
});
}
@@ -757,13 +758,17 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
Future<void> _extractColors(String imageUrl) async {
if (_lastImageUrl == imageUrl) return;
_lastImageUrl = imageUrl;
final extractionId = ++_colorExtractionId;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) {
if (!mounted) return;
if (extractionId != _colorExtractionId) return;
if (colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
@@ -2056,7 +2061,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
: colorScheme.primaryContainer;
// Create a darker shade for the "unplayed" portion of progress bar
final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!;
final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212);
final expandedBg = adaptiveScheme?.surface ?? colorScheme.surface;
final expandedPrimary = adaptiveScheme?.primary;
// Always update to current value - don't preserve stale adaptive colors
_currentExpandedBgColor = expandedBg;
+4 -3
View File
@@ -638,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
+23 -19
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';
@@ -402,7 +403,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
switch (widget.mediaType) {
case ContextMenuMediaType.artist:
final artist = widget.item as Artist;
await maProvider.api?.playArtistRadio(player.playerId, artist);
await maProvider.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
@@ -436,7 +437,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
switch (widget.mediaType) {
case ContextMenuMediaType.artist:
final artist = widget.item as Artist;
await maProvider.api?.playArtistRadio(player.playerId, artist);
await maProvider.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
@@ -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: [
+9 -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
@@ -234,6 +238,7 @@ class _VolumeControlState extends State<VolumeControl> {
children: [
// The slider with gesture detection for precision mode
GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) {
setState(() {
_isDragging = true;
@@ -298,6 +303,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);
}
+2 -2
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"
+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.3+55
version: 3.0.4+56
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