Compare commits

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

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

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

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

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

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

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

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

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

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

Fixes: CollotsSpot/Ensemble#70

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 05:34:39 +00:00
50 changed files with 3311 additions and 392 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
+167
View File
@@ -0,0 +1,167 @@
# Music Assistant 2.8.0 Beta 14 — Impact on Ensemble
**Date:** 2026-03-03
**Current server:** 2.8.0 beta 12
**Target server:** 2.8.0 beta 14 (released March 2, 2026)
---
## Summary
Beta 14 is a large release (50+ PRs). The headline change — merging multi-protocol players into a single entity — directly overlaps with Ensemble's client-side Cast↔Sendspin consolidation logic. Sendspin also received a major v4.0 overhaul. Most changes should degrade gracefully but player ID persistence will break on upgrade.
---
## Breaking: Player Merging (PR #3150)
MA now merges players that support multiple protocols (e.g. a Chromecast with Cast + Sendspin) into a **single player entity** server-side. Previously these appeared as two separate players.
> *"After this change, all your players will be reset. If you previously had multiple players in Music Assistant per protocol, this will now show up as one single player."*
### What Ensemble does today
Ensemble handles this client-side in `music_assistant_provider.dart:4271-4406`:
- `_castToSendspinIdMap` maps Cast IDs → Sendspin IDs
- When Sendspin is grouped: hides Cast player, shows Sendspin (renamed to base name)
- When ungrouped: hides Sendspin, shows original Cast
- Mappings persisted to database for cold start restoration
### Impact on Ensemble
| Component | Location | Impact | Severity |
|---|---|---|---|
| `_castToSendspinIdMap` + consolidation logic | `provider:4271-4406` | Dead code — server no longer sends separate players | Medium |
| `isManuallySyncedPlayer()` ID translation | `provider:533-595` | Lookups against stale map fail silently (returns `false`) | Low |
| Player selection persistence | Settings + DB | **Stored player IDs invalid after upgrade** — all players reset | **High** |
| `_handlePlayerUpdatedEvent()` ID matching | `provider:2956-3358` | Events arrive with new merged IDs, old translation won't match | Medium |
| Database-persisted Cast↔Sendspin mappings | `DatabaseService` | Stale data, won't match anything | Low |
### Assessment
The consolidation logic should degrade gracefully — it won't find Sendspin suffixes to strip or Cast pairs to merge, so the server's already-merged players pass through `filterPlayers()` as-is. The main risk is **player selection breaking** because saved player IDs no longer exist.
### Action required
1. Verify the existing fallback logic (`provider:4427-4465`) gracefully re-selects when the stored `selectedPlayerId` doesn't exist in the player list
2. Remove or gate the Cast↔Sendspin consolidation code behind a server version check (on beta 14+ it's unnecessary and could cause confusion)
3. Clear stale database mappings on first connection to a beta 14+ server
---
## Sendspin v4.0 (PR #3158)
Major rewrite of the Sendspin server.
### Changes
- New `SendspinPlaybackSession` coordinator architecture
- Per-member DSP pipelines, dynamic group membership, late-join audio backfill
- Max buffer capacity 5 → 30 seconds
- Multiple Sendspin servers with automatic failover
- Per-client codec/sample-rate/bit-depth options
- Album artwork persists during skip/pause
- Reduced latency for live streams
### Impact on Ensemble
| Change | Impact |
|---|---|
| New session architecture | **Likely compatible** — sendspin-js only bumped 2.0.0 → 2.0.1 (minor), suggesting wire protocol didn't fundamentally change |
| Per-client codec options | `stream/start` may contain new fields — safe if Ensemble ignores unknown fields |
| Multiple servers with failover | Ensemble's 3-tier connection strategy may need awareness |
| Buffer increase | No client impact |
### Action required
1. Test Sendspin connection against beta 14 — verify `client/hello`, `stream/start`, state reporting all still work
2. Confirm unknown fields in protocol messages are ignored gracefully
---
## Sendspin-Chromecast Bridge (PR #3255)
Replaces the inline experimental Sendspin mode in `ChromecastPlayer` with a dedicated bridge. Cast devices now use a Cast receiver app with an embedded JS Sendspin client.
### Impact on Ensemble
- **Direct impact: None** — Ensemble is a Sendspin client itself, not going through Cast
- **Indirect:** Changes how grouped Cast players behave, which could affect `GroupVolumeManager`
---
## Group Volume / Grouping Fixes
| PR | Change | Ensemble Impact |
|---|---|---|
| #3192 | Fix grouping for players whose native protocol is also a protocol of other players | Bidirectional grouping translation — may affect group detection logic |
| #3205 | Fix group mute for protocol-synced players (`player.state.group_members` vs `player.group_members`) | `GroupVolumeManager` may see different/correct member lists |
| #3277 | Fix `volume_up`/`volume_down` for group players | Server-side fix, should improve behavior |
| #3118 | Fix sync groups losing members on power off | Affects dynamic group management |
### Action required
1. Test group volume behavior — may "just work better" with server-side fixes
2. Verify `GroupVolumeManager` handles any changes to `group_members` field population
---
## Sendspin Bug Fixes
| PR | Fix | Ensemble Relevance |
|---|---|---|
| #3191 | DSP not applying for Sendspin players | DSP config stored on parent player, looked up by protocol player ID — now fixed |
| #3249 | Update to aiosendspin 4.2.0 | Library update, potential behavior changes |
| #3250 | Metadata sending wrong progress when paused | Progress reporting fix — Ensemble's position tracker may see better data |
| #3258 | Floating point for internal audio data | Audio quality improvement, no client impact |
---
## New APIs — Opportunities
| Feature | PR | Details | Ensemble Opportunity |
|---|---|---|---|
| Playback speed | #3198 | `player_queues/set_playback_speed`, stored in `queue.extra_attributes["playback_speed"]` | Great for audiobooks/podcasts |
| Save queue to playlist | #3149 | `save_as_playlist` command on player queue controller | Easy feature add |
| Mixed-media playlists | #3216 | Playlists can contain tracks + podcasts + audiobooks. DB schema bumped to v30. `get_playlist_tracks()` returns `Sequence[PlaylistPlayableItem]` | Playlist screens may need to handle heterogeneous items |
| Genres v2 | #3164 | New browsing category with icons and SVG support | New library section |
---
## Also Changed in Beta 13 (Skipped)
Relevant changes from beta 13 (between current beta 12 and target beta 14):
- Group volume mute support (#3034)
- Frontend prepared for multiprotocol support (#1409) — groundwork for beta 14 merging
- IPv6 support for zeroconf, stream server, AirPlay (#3086)
- Snapcast player availability alignment (#3104)
- DLNA reconnection fix (#3132)
---
## Recommended Action Plan
### Before upgrading
- [ ] Verify player selection fallback handles missing player IDs gracefully
- [ ] Back up current configuration
### Immediate (after upgrading)
- [ ] Test Sendspin protocol compatibility (connection, streaming, state reporting)
- [ ] Test player list — confirm merged players appear correctly without client-side consolidation
- [ ] Test group volume behavior with new server-side fixes
- [ ] Test player selection persistence across app restarts
### Short-term
- [ ] Remove or version-gate Cast↔Sendspin consolidation code
- [ ] Clear stale database mappings on beta 14+ detection
- [ ] Test mixed-media playlist handling if playlists are used
### Opportunistic
- [ ] Add playback speed control (audiobook/podcast feature)
- [ ] Add save-queue-to-playlist
- [ ] Explore Genres v2 browsing
+21 -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).
@@ -156,6 +175,7 @@ For access outside your home network, you'll need to expose Music Assistant thro
A huge thank you to my sponsors! 💖
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
<a href="https://github.com/githubchb"><img src="https://github.com/githubchb.png" width="60px" alt="githubchb" /></a>
## License
+10
View File
@@ -77,6 +77,16 @@ android {
}
}
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86_64': 3]
android.applicationVariants.configureEach { variant ->
variant.outputs.each { output ->
def abiVersionCode = abiCodes[output.getFilter('ABI')]
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
}
flutter {
source '../..'
}
+4
View File
@@ -54,6 +54,10 @@
</intent-filter>
</receiver>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,16.5c-2.49,0 -4.5,-2.01 -4.5,-4.5S9.51,7.5 12,7.5s4.5,2.01 4.5,4.5 -2.01,4.5 -4.5,4.5zM12,11c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55C7.79,13 6,14.79 6,17s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M15,6H3v2h12V6zM15,10H3v2h12V10zM3,16h8v-2H3V16zM17,6v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3V8h3V6H17z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path android:fillColor="#FFFFFF"
android:pathData="M3.24,6.15C2.51,6.43 2,7.17 2,8v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2H8.3l8.26,-3.34 -0.37,-0.92L3.24,6.15zM7,20c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM20,12H12V9h8v3z"/>
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+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);
}
+1
View File
@@ -144,6 +144,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider();
_localeProvider = LocaleProvider();
audioHandler.setProvider(_musicProvider);
WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception
+289 -42
View File
@@ -26,6 +26,7 @@ import '../services/group_volume_manager.dart';
import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.dart';
import '../services/image_prefetch_service.dart';
import '../constants/timings.dart';
import '../services/database_service.dart';
import '../services/library_status_service.dart';
@@ -47,6 +48,7 @@ class MusicAssistantProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService();
final PositionTracker _positionTracker = PositionTracker();
ImagePrefetchService? _imagePrefetchService;
final GroupVolumeManager _groupVolumeManager = GroupVolumeManager();
MAConnectionState _connectionState = MAConnectionState.disconnected;
@@ -58,6 +60,8 @@ class MusicAssistantProvider with ChangeNotifier {
List<Album> _albums = [];
List<Track> _tracks = [];
List<Track> _cachedFavoriteTracks = []; // Cached for instant display before full library loads
List<Audiobook> _audiobooks = [];
final Map<String, List<Audiobook>> _seriesAudiobooksCache = {};
List<MediaItem> _radioStations = [];
List<MediaItem> _podcasts = [];
bool _isLoading = false;
@@ -165,6 +169,7 @@ class MusicAssistantProvider with ChangeNotifier {
List<Artist> get artists => filterByProvider(_artists);
List<Album> get albums => filterByProvider(_albums);
List<Track> get tracks => filterByProvider(_tracks);
List<Audiobook> get audiobooks => filterByProvider(_audiobooks);
List<MediaItem> get radioStations => filterByProvider(_radioStations);
List<MediaItem> get podcasts => filterByProvider(_podcasts);
@@ -307,7 +312,7 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get providers that have audiobooks (with item counts)
Map<String, int> getProvidersWithAudiobooks() {
return _getProvidersWithCounts(SyncService.instance.cachedAudiobooks);
return _getProvidersWithCounts(_audiobooks);
}
/// Get providers that have radio stations (with item counts)
@@ -596,6 +601,9 @@ class MusicAssistantProvider with ChangeNotifier {
/// Home refresh counter - increments when home rows should refresh their data
int get homeRefreshCounter => _homeRefreshCounter;
/// Current local (Sendspin) player volume as tracked by MA (0-100).
int get localPlayerVolume => _localPlayerVolume;
/// Currently playing audiobook context (with chapters) - set when playing an audiobook
Audiobook? get currentAudiobook => _currentAudiobook;
@@ -942,7 +950,6 @@ class MusicAssistantProvider with ChangeNotifier {
if (syncService.hasCache) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
// Note: tracks are loaded separately via API, not cached in SyncService
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
@@ -1724,6 +1731,9 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.invalidateAllPlaylistTracksCaches();
} else if (mediaType == 'artist') {
_cacheService.invalidateHomeArtistCaches();
} else if (mediaType == 'audiobook') {
_cacheService.invalidateAudiobookCaches();
_cacheService.invalidateHomeCache();
}
return true;
} catch (e) {
@@ -1762,6 +1772,9 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.invalidateAllPlaylistTracksCaches();
} else if (mediaType == 'artist') {
_cacheService.invalidateHomeArtistCaches();
} else if (mediaType == 'audiobook') {
_cacheService.invalidateAudiobookCaches();
_cacheService.invalidateHomeCache();
}
try {
@@ -1842,6 +1855,12 @@ class MusicAssistantProvider with ChangeNotifier {
!matchesLibraryId(p.provider, p.itemId, p.providerMappings)
).toList();
updated = _podcasts.length != before;
} else if (mediaType == 'audiobook') {
final before = _audiobooks.length;
_audiobooks = _audiobooks.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _audiobooks.length != before;
}
if (updated) {
@@ -1871,6 +1890,9 @@ class MusicAssistantProvider with ChangeNotifier {
_radioStations = await _api!.getRadioStations(limit: 100);
} else if (mediaType == 'podcast') {
_podcasts = await _api!.getPodcasts(limit: 100);
} else if (mediaType == 'audiobook') {
_audiobooks = await _api!.getAudiobooks(limit: LibraryConstants.maxLibraryItems);
_cacheService.invalidateAudiobookCaches();
}
_syncLibraryStatusToService();
notifyListeners();
@@ -1892,6 +1914,8 @@ class MusicAssistantProvider with ChangeNotifier {
_mediaItemAddedEventSubscription?.cancel();
_mediaItemDeletedEventSubscription?.cancel();
_positionTracker.clear();
_imagePrefetchService?.cancel();
_imagePrefetchService = null;
// Disconnect Sendspin and PCM player if connected
if (_sendspinConnected) {
await _pcmAudioPlayer?.disconnect();
@@ -2418,11 +2442,21 @@ class MusicAssistantProvider with ChangeNotifier {
}
/// Handle Sendspin volume command
void _handleSendspinVolume(int volumeLevel) async {
void _handleSendspinVolume(int volumeLevel) {
_logger.log('🔊 Sendspin: Set volume to $volumeLevel');
_localPlayerVolume = volumeLevel;
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
FlutterVolumeController.setVolume(volumeLevel / 100.0);
_sendspinService?.reportState(volume: volumeLevel);
notifyListeners();
}
/// Called when the user changes the local player volume from within Ensemble.
/// Updates [_localPlayerVolume] so the periodic state report reflects the real
/// volume, and applies the same gain to the PCM stream.
void updateLocalPlayerVolume(int volumeLevel) {
_localPlayerVolume = volumeLevel.clamp(0, 100);
_sendspinService?.reportState(volume: _localPlayerVolume);
notifyListeners();
}
/// Handle Sendspin stream start - server is about to send PCM audio data
@@ -2679,7 +2713,8 @@ class MusicAssistantProvider with ChangeNotifier {
final volume = event['volume_level'] as int? ?? event['volume'] as int?;
if (volume != null) {
_localPlayerVolume = volume;
await FlutterVolumeController.setVolume(volume / 100.0);
FlutterVolumeController.setVolume(volume / 100.0);
notifyListeners();
}
break;
@@ -2786,6 +2821,14 @@ class MusicAssistantProvider with ChangeNotifier {
_availablePlayers[idx] =
_availablePlayers[idx].copyWith(volumeLevel: eventVolume);
}
// If this is the local Sendspin player, sync the tracked volume and slider.
// Do NOT call FlutterVolumeController here — _handleSendspinVolume already
// sets system volume from the server/command message; calling it again from
// player_updated would create a feedback loop via the volume listener.
if (playerId == _sendspinService?.playerId && eventVolume != _localPlayerVolume) {
_localPlayerVolume = eventVolume;
notifyListeners();
}
}
final currentMedia = event['current_media'] as Map<String, dynamic>?;
@@ -3020,7 +3063,7 @@ class MusicAssistantProvider with ChangeNotifier {
_cacheService.setCachedTrackForPlayer(playerId, mergedTrack);
_logger.log('📋 Merged album info into existing track for $playerName: ${albumName}');
} else {
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
_logger.debug('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
}
// For selected player, _updatePlayerState() is already called above which fetches queue data
@@ -3430,7 +3473,7 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get favorite tracks from the library
Future<List<Track>> getFavoriteTracks() async {
// If full tracks list is loaded, filter from it
// Tier 1: filter from in-memory tracks list
if (_tracks.isNotEmpty) {
final favTracks = _tracks.where((t) => t.favorite == true).toList();
// Update cache if we have new data
@@ -3440,8 +3483,24 @@ class MusicAssistantProvider with ChangeNotifier {
}
return favTracks;
}
// Otherwise return cached favorites (for instant display before full library loads)
return _cachedFavoriteTracks;
// Tier 2: return persisted cache
if (_cachedFavoriteTracks.isNotEmpty) {
return _cachedFavoriteTracks;
}
// Tier 3: fetch from API directly (e.g. Android Auto before library loads)
if (_api != null) {
try {
final apiFavs = await _api!.getTracks(favoriteOnly: true);
if (apiFavs.isNotEmpty) {
_cachedFavoriteTracks = apiFavs;
_persistFavoriteTracks(apiFavs);
}
return apiFavs;
} catch (e) {
_logger.log('❌ Failed to fetch favorite tracks from API: $e');
}
}
return [];
}
/// Get favorite playlists from the library
@@ -3607,7 +3666,7 @@ class MusicAssistantProvider with ChangeNotifier {
// DETAIL SCREEN CACHING
// ============================================================================
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false, Album? album}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
@@ -3615,12 +3674,36 @@ class MusicAssistantProvider with ChangeNotifier {
return _cacheService.getCachedAlbumTracks(cacheKey)!;
}
// Try database fallback before hitting API
if (!forceRefresh) {
final dbTracks = await _cacheService.getAlbumTracksWithDbFallback(cacheKey);
if (dbTracks != null) return dbTracks;
}
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
// Use actual provider mapping instead of "library" provider when available.
// The MA server caches tracks per provider, so using the actual Spotify/etc
// provider ID hits the server-side cache instead of re-resolving every time.
var fetchProvider = provider;
var fetchItemId = itemId;
if (album?.providerMappings != null) {
final nonLibrary = album!.providerMappings!
.where((m) => m.providerInstance != 'library' && m.available)
.toList();
if (nonLibrary.isNotEmpty) {
fetchProvider = nonLibrary.first.providerInstance;
fetchItemId = nonLibrary.first.itemId;
_logger.log('🔀 Using provider mapping: $fetchProvider/$fetchItemId instead of $provider/$itemId');
}
}
try {
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
final tracks = await _api!.getAlbumTracks(provider, itemId);
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
_logger.log('🔄 Fetching fresh album tracks for $fetchProvider/$fetchItemId...');
final tracks = await _api!.getAlbumTracks(fetchProvider, fetchItemId);
if (tracks.isNotEmpty) {
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
}
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch album tracks: $e');
@@ -3636,12 +3719,20 @@ class MusicAssistantProvider with ChangeNotifier {
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
}
// Try database fallback before hitting API
if (!forceRefresh) {
final dbTracks = await _cacheService.getPlaylistTracksWithDbFallback(cacheKey);
if (dbTracks != null) return dbTracks;
}
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
final tracks = await _api!.getPlaylistTracks(provider, itemId);
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
if (tracks.isNotEmpty) {
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
}
return tracks;
} catch (e) {
_logger.log('❌ Failed to fetch playlist tracks: $e');
@@ -3649,6 +3740,76 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<Audiobook?> getAudiobookDetailsWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
final cacheKey = '${provider}_$itemId';
if (_cacheService.isAudiobookDetailCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached audiobook detail for $cacheKey');
final json = _cacheService.getCachedAudiobookDetail(cacheKey)!;
try {
return Audiobook.fromJson(jsonDecode(json) as Map<String, dynamic>);
} catch (_) {}
}
// Try database fallback before hitting API
if (!forceRefresh) {
final dbJson = await _cacheService.getAudiobookDetailWithDbFallback(cacheKey);
if (dbJson != null) {
try {
return Audiobook.fromJson(jsonDecode(dbJson) as Map<String, dynamic>);
} catch (_) {}
}
}
if (_api == null) return null;
try {
_logger.log('🔄 Fetching audiobook details for $cacheKey...');
final audiobook = await _api!.getAudiobookDetails(provider, itemId);
if (audiobook != null) {
_cacheService.setCachedAudiobookDetail(cacheKey, jsonEncode(audiobook.toJson()));
}
return audiobook;
} catch (e) {
_logger.log('❌ Failed to fetch audiobook details: $e');
// Try cached version on error
final cached = _cacheService.getCachedAudiobookDetail(cacheKey);
if (cached != null) {
try {
return Audiobook.fromJson(jsonDecode(cached) as Map<String, dynamic>);
} catch (_) {}
}
return null;
}
}
Future<List<MediaItem>> getPodcastEpisodesWithCache(String podcastId, {String? provider, bool forceRefresh = false}) async {
final cacheKey = '${provider ?? 'default'}_$podcastId';
if (_cacheService.isPodcastEpisodesCacheValid(cacheKey, forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached podcast episodes for $cacheKey');
return _cacheService.getCachedPodcastEpisodes(cacheKey)!;
}
// Try database fallback before hitting API
if (!forceRefresh) {
final dbEpisodes = await _cacheService.getPodcastEpisodesWithDbFallback(cacheKey);
if (dbEpisodes != null) return dbEpisodes;
}
if (_api == null) return _cacheService.getCachedPodcastEpisodes(cacheKey) ?? [];
try {
_logger.log('🔄 Fetching podcast episodes for $cacheKey...');
final episodes = await _api!.getPodcastEpisodes(podcastId, provider: provider);
_cacheService.setCachedPodcastEpisodes(cacheKey, episodes);
return episodes;
} catch (e) {
_logger.log('❌ Failed to fetch podcast episodes: $e');
return _cacheService.getCachedPodcastEpisodes(cacheKey) ?? [];
}
}
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
final cacheKey = artistName.toLowerCase();
@@ -3662,10 +3823,8 @@ class MusicAssistantProvider with ChangeNotifier {
try {
_logger.log('🔄 Fetching albums for artist "$artistName"...');
// Fetch all library albums (with high limit to get full library)
final libraryAlbums = await _api!.getAlbums(limit: LibraryConstants.maxLibraryItems);
_logger.log('📚 Fetched ${libraryAlbums.length} total library albums');
final artistAlbums = libraryAlbums.where((album) {
// Use already-loaded library albums instead of re-fetching from API
final artistAlbums = _albums.where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
@@ -3767,6 +3926,52 @@ class MusicAssistantProvider with ChangeNotifier {
return _cacheService.getCachedArtistAlbums(artistName.toLowerCase());
}
/// Get artist albums from in-memory library (synchronous, no API call)
List<Album> getArtistAlbumsFromLibrary(String artistName) {
final lowerName = artistName.toLowerCase();
return _albums.where((album) {
final albumArtists = album.artists;
if (albumArtists == null || albumArtists.isEmpty) return false;
return albumArtists.any((a) => a.name.toLowerCase() == lowerName);
}).toList();
}
/// Get cached audiobook detail from memory (synchronous, for instant display)
Audiobook? getCachedAudiobookDetail(String provider, String itemId) {
final cacheKey = '${provider}_$itemId';
final json = _cacheService.getCachedAudiobookDetail(cacheKey);
if (json == null) return null;
try {
return Audiobook.fromJson(jsonDecode(json) as Map<String, dynamic>);
} catch (_) {
return null;
}
}
/// Get cached podcast episodes from memory (synchronous, for instant display)
List<MediaItem>? getCachedPodcastEpisodes(String podcastId, {String? provider}) {
final cacheKey = '${provider ?? 'default'}_$podcastId';
return _cacheService.getCachedPodcastEpisodes(cacheKey);
}
/// Get cached series audiobooks (synchronous, for instant display)
List<Audiobook>? getCachedSeriesAudiobooks(String seriesPath) {
return _seriesAudiobooksCache[seriesPath];
}
/// Get series audiobooks with cache-first pattern
Future<List<Audiobook>> getSeriesAudiobooksWithCache(String seriesPath, {bool forceRefresh = false}) async {
if (!forceRefresh && _seriesAudiobooksCache.containsKey(seriesPath)) {
return _seriesAudiobooksCache[seriesPath]!;
}
if (_api == null) return _seriesAudiobooksCache[seriesPath] ?? [];
final books = await _api!.getSeriesAudiobooks(seriesPath);
_seriesAudiobooksCache[seriesPath] = books;
return books;
}
// ============================================================================
// PLAYER SELECTION
// ============================================================================
@@ -4351,7 +4556,7 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
// Sendspin PCM player - continue to start timer
_logger.log('🔔 Starting notification timer for Sendspin PCM player');
_logger.debug('🔔 Starting notification timer for Sendspin PCM player');
}
// Only run timer if player is playing
@@ -4595,7 +4800,7 @@ class MusicAssistantProvider with ChangeNotifier {
/// Called when playback stops (player state becomes idle)
void _startIdleServiceTimer() {
_idleServiceTimer?.cancel();
_logger.log('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
_logger.debug('⏱️ Starting idle service timer (${_idleServiceTimeout.inMinutes} min)');
_idleServiceTimer = Timer(_idleServiceTimeout, () {
_logger.log('⏱️ Idle service timeout reached - stopping foreground service');
audioHandler.stopService();
@@ -4648,6 +4853,9 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
// Don't poll when not connected - avoids "Not authenticated" spam
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
// Capture player ID at start to detect stale operations
// If player changes during async operations, we should abort
final operationPlayerId = _selectedPlayer!.playerId;
@@ -4784,10 +4992,17 @@ class MusicAssistantProvider with ChangeNotifier {
if (!shouldShowTrack) {
if (_currentTrack != null) {
_currentTrack = null;
// Player went idle - try to keep last-known track visible for resume
final lastKnown = _cacheService.getLastKnownTrack(_selectedPlayer!.playerId);
if (lastKnown != null && _selectedPlayer!.state == 'idle') {
_logger.log('🔄 Player idle-out: keeping last-known track "${lastKnown.name}" for resume');
_currentTrack = lastKnown;
} else {
_currentTrack = null;
// Persist cleared track state
_persistPlaybackState();
}
stateChanged = true;
// Persist cleared track state
_persistPlaybackState();
}
// Clear audiobook context when playback stops
if (_currentAudiobook != null) {
@@ -4901,6 +5116,9 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
// Save last-known track for resume after idle-out
_cacheService.setLastKnownTrack(_selectedPlayer!.playerId, _currentTrack!);
// Update notification for ALL players
final track = _currentTrack!;
final artworkUrl = _api?.getImageUrl(track, size: 512);
@@ -5041,7 +5259,15 @@ class MusicAssistantProvider with ChangeNotifier {
// For pause: Don't refresh - we already did optimistic UI update
// The player_updated event from MA will handle final state sync
} else {
await resumePlayer(_selectedPlayer!.playerId);
// If player is idle with no active queue but has a last-known track, re-queue it
final isIdle = _selectedPlayer!.state == 'idle';
final lastKnown = _cacheService.getLastKnownTrack(_selectedPlayer!.playerId);
if (isIdle && lastKnown != null) {
_logger.log('🔄 Player idle with last-known track - re-queuing "${lastKnown.name}"');
await playTrack(_selectedPlayer!.playerId, lastKnown);
} else {
await resumePlayer(_selectedPlayer!.playerId);
}
// For resume: Refresh in background to get updated track info
unawaited(refreshPlayers());
}
@@ -5108,24 +5334,12 @@ class MusicAssistantProvider with ChangeNotifier {
if (syncService.hasCache) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
_audiobooks = syncService.cachedAudiobooks;
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists, ${_audiobooks.length} audiobooks from cache');
_syncLibraryStatusToService();
notifyListeners();
}
// Fetch tracks from API (not cached - too many items)
if (_api != null) {
try {
_tracks = await _api!.getTracks(
limit: LibraryConstants.maxLibraryItems,
providerInstanceIds: providerIdsForApiCalls,
);
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
} catch (e) {
_logger.log('⚠️ Failed to fetch tracks: $e');
}
}
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
@@ -5153,9 +5367,14 @@ class MusicAssistantProvider with ChangeNotifier {
if (syncService.status == SyncStatus.completed) {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
_audiobooks = syncService.cachedAudiobooks;
if (syncService.cachedPodcasts.isNotEmpty) {
_podcasts = syncService.cachedPodcasts;
}
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists, ${_audiobooks.length} audiobooks, ${_podcasts.length} podcasts');
_syncLibraryStatusToService();
notifyListeners();
_prefetchAlbumImages();
}
syncService.removeListener(onSyncComplete);
}
@@ -5164,6 +5383,26 @@ class MusicAssistantProvider with ChangeNotifier {
await syncService.syncFromApi(_api!, providerInstanceIds: providerIdsForApiCalls);
}
/// Prefetch album and audiobook images in background after sync
void _prefetchAlbumImages() {
_imagePrefetchService?.cancel();
_imagePrefetchService = ImagePrefetchService();
final urls = <String>[];
for (final album in _albums) {
final url = _api?.getImageUrl(album, size: 256);
if (url != null) urls.add(url);
}
for (final audiobook in SyncService.instance.cachedAudiobooks) {
final url = _api?.getImageUrl(audiobook, size: 256);
if (url != null) urls.add(url);
}
if (urls.isEmpty) return;
_logger.log('🖼️ Starting image prefetch for ${urls.length} items');
_imagePrefetchService!.prefetchImages(urls);
}
/// Load radio stations from the library
Future<void> loadRadioStations({String? orderBy}) async {
if (!isConnected || _api == null) return;
@@ -5185,9 +5424,17 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> loadPodcasts({String? orderBy}) async {
if (!isConnected || _api == null) return;
// Load from cache first (instant)
final syncService = SyncService.instance;
if (syncService.cachedPodcasts.isNotEmpty && _podcasts.isEmpty) {
_podcasts = syncService.cachedPodcasts;
_logger.log('📦 Loaded ${_podcasts.length} podcasts from cache');
notifyListeners();
}
try {
_isLoadingPodcasts = true;
notifyListeners();
if (_podcasts.isEmpty) notifyListeners();
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
@@ -5483,7 +5730,7 @@ class MusicAssistantProvider with ChangeNotifier {
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
_logger.debug('💾 Persisted ${queue.items.length} queue items to database');
} catch (e) {
_logger.log('⚠️ Error persisting queue to database: $e');
}
@@ -6114,7 +6361,7 @@ class MusicAssistantProvider with ChangeNotifier {
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId) {
_localPlayerVolume = volumeLevel;
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
FlutterVolumeController.setVolume(volumeLevel / 100.0);
}
await _api?.setVolume(playerId, volumeLevel);
} catch (e) {
+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, _) {
+28 -19
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,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
),
),
),
// Bottom fade gradient to mask content behind mini player
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.miniPlayerHeight + 22.0,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background.withOpacity(0.25),
colorScheme.background.withOpacity(0.85),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
),
],
);
},
@@ -483,8 +504,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// 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
// Subtract mini player overlay space (mini player + its margins sit above the nav bar).
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0; // 84 + 22 = 106
final availableHeight = constraints.maxHeight - miniPlayerSpace;
// Account for margins between rows (2px each, 2 margins for 3 rows).
@@ -493,19 +514,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 +536,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,
+4 -3
View File
@@ -6079,8 +6079,9 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
// Position menu below and to the left of the button
final menuWidth = 220.0;
final screenWidth = MediaQuery.of(context).size.width;
final left = (widget.position.dx + widget.buttonSize.width - menuWidth).clamp(8.0, screenWidth - menuWidth - 8);
final screenSize = MediaQuery.of(context).size;
final bottomInset = BottomSpacing.withMiniPlayer + MediaQuery.of(context).viewPadding.bottom;
final left = (widget.position.dx + widget.buttonSize.width - menuWidth).clamp(8.0, screenSize.width - menuWidth - 8);
final top = widget.position.dy + widget.buttonSize.height + 4;
return Stack(
@@ -6110,7 +6111,7 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
child: Container(
width: menuWidth,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height - top - 50,
maxHeight: screenSize.height - top - bottomInset,
),
child: SingleChildScrollView(
child: Column(
+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;
});
}
}
+25 -4
View File
@@ -314,6 +314,10 @@ class SearchScreenState extends State<SearchScreen> {
void requestFocus() {
if (mounted) {
setState(() {
_isSearchBarVisible = true;
_lastVerticalScrollOffset = 0;
});
_focusNode.requestFocus();
}
}
@@ -817,6 +821,7 @@ class SearchScreenState extends State<SearchScreen> {
child: ListView.builder(
// PERF: Use key to preserve scroll position per filter
key: PageStorageKey('list_$filterForPage'),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(16, 16, 16, BottomSpacing.navBarOnly),
cacheExtent: 1000,
addAutomaticKeepAlives: false,
@@ -1196,6 +1201,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1238,6 +1244,7 @@ class SearchScreenState extends State<SearchScreen> {
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
onTap: () {
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1266,6 +1273,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1344,6 +1352,7 @@ class SearchScreenState extends State<SearchScreen> {
),
),
onTap: () {
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1372,6 +1381,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1440,7 +1450,10 @@ class SearchScreenState extends State<SearchScreen> {
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
)
: null,
onTap: () => _playTrack(track),
onTap: () {
removeFocus();
_playTrack(track);
},
),
),
);
@@ -1458,6 +1471,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1546,6 +1560,7 @@ class SearchScreenState extends State<SearchScreen> {
)
: null,
onTap: () {
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1576,6 +1591,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1651,7 +1667,7 @@ class SearchScreenState extends State<SearchScreen> {
)
: null,
onTap: () {
// Update adaptive colors before navigation
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
@@ -1678,6 +1694,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1726,7 +1743,10 @@ class SearchScreenState extends State<SearchScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => _playRadioStation(radio),
onTap: () {
removeFocus();
_playRadioStation(radio);
},
),
),
);
@@ -1744,6 +1764,7 @@ class SearchScreenState extends State<SearchScreen> {
return RepaintBoundary(
child: GestureDetector(
onLongPressStart: (details) {
removeFocus();
MediaContextMenu.show(
context: context,
position: details.globalPosition,
@@ -1800,7 +1821,7 @@ class SearchScreenState extends State<SearchScreen> {
overflow: TextOverflow.ellipsis,
),
onTap: () {
// Update adaptive colors before navigation
removeFocus();
updateAdaptiveColorsFromImage(context, imageUrl);
Navigator.push(
context,
+770 -1
View File
@@ -2,12 +2,17 @@ import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import 'package:rxdart/rxdart.dart';
import '../debug_logger.dart';
import '../auth/auth_manager.dart';
import '../settings_service.dart';
import '../sync_service.dart';
import '../../providers/music_assistant_provider.dart';
import '../../models/media_item.dart' as ma;
/// Custom AudioHandler for Ensemble that provides full control over
/// notification actions and metadata updates.
class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer();
final AuthManager authManager;
final _logger = DebugLogger();
@@ -32,6 +37,16 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
// Track whether we're in remote control mode (controlling MA player, not playing locally)
bool _isRemoteMode = false;
// Android Auto: provider reference (set after app initialises)
MusicAssistantProvider? _autoProvider;
// Android Auto: track queue cache — maps context key to ordered track list.
// Populated during getChildren so playFromMediaId can queue the full album/playlist.
final Map<String, List<ma.Track>> _autoTrackCache = {};
// Android Auto: subjects for subscribeToChildren
final Map<String, BehaviorSubject<Map<String, dynamic>>> _autoChildrenSubjects = {};
// Custom control for switching players (uses stop action with custom icon)
static const _switchPlayerControl = MediaControl(
androidIcon: 'drawable/ic_switch_player',
@@ -312,6 +327,760 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
MediaItem? get currentMediaItem => _currentMediaItem;
// ---------------------------------------------------------------------------
// Android Auto — media browsing and playback
// ---------------------------------------------------------------------------
/// Wire the provider in after the app has initialised.
/// Called from _MusicAssistantAppState.initState().
void setProvider(MusicAssistantProvider provider) {
_autoProvider = provider;
_logger.log('AndroidAuto: provider set');
}
// Media ID constants — root categories
static const _autoIdHome = 'cat|home';
static const _autoIdMusic = 'cat|music';
static const _autoIdAudiobooks = 'cat|audiobooks';
static const _autoIdPodcasts = 'cat|podcasts';
static const _autoIdRadio = 'cat|radio';
// Media ID constants — Music subcategories
static const _autoIdPlaylists = 'cat|playlists';
static const _autoIdArtists = 'cat|artists';
static const _autoIdAlbums = 'cat|albums';
static const _autoIdFavorites = 'cat|favorites';
// Media ID constants — Audiobook subcategories
static const _autoIdAbAuthors = 'cat|ab_authors';
static const _autoIdAbBooks = 'cat|ab_books';
static const _autoIdAbSeries = 'cat|ab_series';
// Android Auto content style hints
static const _gridHints = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 2,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
};
// Icon URIs for category items
static const _iconPkg = 'com.collotsspot.ensemble';
static final _iconHome = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_home');
static final _iconMusic = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_music');
static final _iconBook = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_book');
static final _iconPodcast = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_podcast');
static final _iconRadio = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_radio');
static final _iconArtist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_artist');
static final _iconAlbum = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_album');
static final _iconPlaylist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_playlist');
static final _iconFavorite = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_favorite');
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
final provider = _autoProvider;
if (provider == null) {
_logger.log('AndroidAuto: getChildren("$parentMediaId") — provider not set yet');
return [];
}
// Ensure provider is connected — AA may browse before app is fully ready
if (!provider.isConnected) {
_logger.log('AndroidAuto: provider disconnected, starting reconnect');
// Don't await — reconnection can take seconds and AA may timeout
// getChildren. Cached data is served immediately; live API calls
// inside builders will await their own reconnect if needed.
provider.checkAndReconnect();
}
try {
final result = await _autoGetChildren(provider, parentMediaId);
_logger.log('AndroidAuto: getChildren "$parentMediaId" → ${result.length} items');
return result;
} catch (e, st) {
_logger.log('AndroidAuto: getChildren error for "$parentMediaId": $e\n$st');
return [];
}
}
Future<List<MediaItem>> _autoGetChildren(
MusicAssistantProvider provider, String parentMediaId) async {
switch (parentMediaId) {
// Root
case AudioService.browsableRootId:
return _autoBuildRoot();
// Home — subcategory folders matching user's homescreen settings
case _autoIdHome:
return _autoBuildHome();
// Music
case _autoIdMusic:
return _autoBuildMusicCategories();
case _autoIdPlaylists:
return _autoBuildPlaylistList(provider);
case _autoIdArtists:
return _autoBuildArtistList(provider);
case _autoIdAlbums:
return _autoBuildAlbumList(provider);
case _autoIdFavorites:
return _autoBuildFavorites(provider);
// Audiobooks
case _autoIdAudiobooks:
return _autoBuildAudiobookCategories();
case _autoIdAbAuthors:
return _autoBuildAudiobookAuthorList(provider);
case _autoIdAbBooks:
return _autoBuildAudiobookList(provider);
case _autoIdAbSeries:
return _autoBuildAudiobookSeriesList(provider);
// Podcasts
case _autoIdPodcasts:
return _autoBuildPodcastList(provider);
// Radio
case _autoIdRadio:
return _autoBuildRadioList(provider);
default:
// Home row content (home|recent-albums, etc.)
if (parentMediaId.startsWith('home|')) {
return _autoBuildHomeRowContent(provider, parentMediaId.substring(5));
}
return _autoBuildDynamicChildren(provider, parentMediaId);
}
}
/// Route prefix-based dynamic media IDs (albums, playlists, artists, etc.)
Future<List<MediaItem>> _autoBuildDynamicChildren(
MusicAssistantProvider provider, String parentMediaId) async {
final parts = parentMediaId.split('|');
if (parts.length < 2) return [];
switch (parts[0]) {
case 'playlist':
if (parts.length >= 3) {
return _autoBuildPlaylistTracks(provider, parts[1], parts[2]);
}
case 'album':
if (parts.length >= 3) {
return _autoBuildAlbumTracks(provider, parts[1], parts[2]);
}
case 'artist':
final name = parts.sublist(1).join('|');
return _autoBuildArtistAlbums(provider, name);
case 'podcast':
if (parts.length >= 3) {
return _autoBuildPodcastEpisodes(provider, parts[1], parts[2]);
}
case 'ab_author':
final authorName = parts.sublist(1).join('|');
return _autoBuildAuthorAudiobooks(provider, authorName);
case 'ab_series':
final seriesPath = parts.sublist(1).join('|');
return _autoBuildSeriesAudiobooks(provider, seriesPath);
}
_logger.log('AndroidAuto: unhandled parentMediaId "$parentMediaId"');
return [];
}
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
_autoChildrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
return _autoChildrenSubjects[parentMediaId]!.stream;
}
@override
Future<void> playFromMediaId(String mediaId,
[Map<String, dynamic>? extras]) async {
final provider = _autoProvider;
if (provider == null) {
_logger.log('AndroidAuto: playFromMediaId called before provider is set');
return;
}
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) {
_logger.log('AndroidAuto: no builtin player ID — cannot play');
return;
}
// Auto-switch to the builtin (local) player when AA triggers playback
if (provider.selectedPlayer?.playerId != playerId) {
final builtinPlayer = provider.availablePlayersUnfiltered
.where((p) => p.playerId == playerId)
.firstOrNull;
if (builtinPlayer != null) {
_logger.log('AndroidAuto: switching to builtin player "${builtinPlayer.name}"');
provider.selectPlayer(builtinPlayer);
}
}
_logger.log('AndroidAuto: playFromMediaId $mediaId');
try {
if (mediaId.startsWith('track|')) {
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
final parts = mediaId.split('|');
if (parts.length < 6) return;
final tProvider = parts[1];
final tItemId = parts[2];
final ctxKey = parts.sublist(3).join('|');
final trackList = _autoTrackCache[ctxKey];
if (trackList == null || trackList.isEmpty) {
_logger.log('AndroidAuto: cache miss for $ctxKey, playing single track');
await provider.playTrack(
playerId,
ma.Track(itemId: tItemId, provider: tProvider, name: ''),
);
return;
}
final index = trackList.indexWhere(
(t) => t.provider == tProvider && t.itemId == tItemId,
);
await provider.playTracks(
playerId,
trackList,
startIndex: index < 0 ? 0 : index,
);
return;
}
if (mediaId.startsWith('radio|')) {
// Format: radio|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) await provider.checkAndReconnect();
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play radio');
return;
}
final station = provider.radioStations.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => provider.radioStationsUnfiltered.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => throw Exception('Radio station not found: $mediaId'),
),
);
await provider.api!.playRadioStation(playerId, station);
return;
}
if (mediaId.startsWith('audiobook|')) {
// Format: audiobook|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) {
await provider.checkAndReconnect();
}
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play audiobook');
return;
}
final book = SyncService.instance.cachedAudiobooks.firstWhere(
(b) => b.provider == parts[1] && b.itemId == parts[2],
orElse: () => throw Exception('Audiobook not found: $mediaId'),
);
await provider.api!.playAudiobook(playerId, book);
return;
}
if (mediaId.startsWith('podcast_ep|')) {
// Format: podcast_ep|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) await provider.checkAndReconnect();
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play podcast');
return;
}
// Use provider-specific URI (e.g. spotify--xxx://podcast_episode/id)
// instead of library:// which fails for non-library items
final uri = '${parts[1]}://podcast_episode/${parts[2]}';
final episode = ma.MediaItem(
itemId: parts[2],
provider: parts[1],
name: '',
mediaType: ma.MediaType.podcastEpisode,
uri: uri,
);
_logger.log('AndroidAuto: playing podcast episode URI: $uri');
await provider.api!.playPodcastEpisode(playerId, episode);
return;
}
} catch (e) {
_logger.log('AndroidAuto: playFromMediaId error: $e');
}
}
@override
Future<List<MediaItem>> search(String query,
[Map<String, dynamic>? extras]) async {
final provider = _autoProvider;
if (provider == null || query.trim().isEmpty) return [];
try {
final results = await provider.searchWithCache(query);
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
const ctxKey = 'search||';
_autoTrackCache[ctxKey] = tracks;
return tracks
.map((t) => _autoTrackItem(provider, t, ctxKey))
.toList();
} catch (e) {
_logger.log('AndroidAuto: search error: $e');
return [];
}
}
// --- Root & category builders ---
List<MediaItem> _autoBuildRoot() {
return [
MediaItem(id: _autoIdHome, title: 'Home', playable: false,
artUri: _iconHome),
MediaItem(id: _autoIdMusic, title: 'Music', playable: false,
artUri: _iconMusic),
MediaItem(id: _autoIdAudiobooks, title: 'Audiobooks', playable: false,
artUri: _iconBook),
MediaItem(id: _autoIdPodcasts, title: 'Podcasts', playable: false,
artUri: _iconPodcast, extras: _gridHints),
MediaItem(id: _autoIdRadio, title: 'Radio', playable: false,
artUri: _iconRadio, extras: _gridHints),
];
}
// Row ID → display title mapping
static const _homeRowTitles = {
'recent-albums': 'Recent Albums',
'discover-artists': 'Discover Artists',
'discover-albums': 'Discover Albums',
'continue-listening': 'Continue Listening',
'discover-audiobooks': 'Discover Audiobooks',
'discover-series': 'Discover Series',
'favorite-albums': 'Favourite Albums',
'favorite-artists': 'Favourite Artists',
'favorite-tracks': 'Favourite Tracks',
'favorite-playlists': 'Favourite Playlists',
'favorite-radio-stations': 'Favourite Radio',
'favorite-podcasts': 'Favourite Podcasts',
};
/// Home shows subcategory folders matching the user's homescreen settings.
Future<List<MediaItem>> _autoBuildHome() async {
final rowOrder = await SettingsService.getHomeRowOrder();
final items = <MediaItem>[];
for (final rowId in rowOrder) {
if (!await _isHomeRowEnabled(rowId)) continue;
final title = _homeRowTitles[rowId];
if (title == null) continue; // skip discovery rows for now
items.add(MediaItem(
id: 'home|$rowId',
title: title,
playable: false,
extras: _gridHints,
));
}
_logger.log('AndroidAuto: Home built ${items.length} rows');
return items;
}
Future<bool> _isHomeRowEnabled(String rowId) async {
switch (rowId) {
case 'recent-albums': return SettingsService.getShowRecentAlbums();
case 'discover-artists': return SettingsService.getShowDiscoverArtists();
case 'discover-albums': return SettingsService.getShowDiscoverAlbums();
case 'continue-listening': return SettingsService.getShowContinueListeningAudiobooks();
case 'discover-audiobooks': return SettingsService.getShowDiscoverAudiobooks();
case 'discover-series': return SettingsService.getShowDiscoverSeries();
case 'favorite-albums': return SettingsService.getShowFavoriteAlbums();
case 'favorite-artists': return SettingsService.getShowFavoriteArtists();
case 'favorite-tracks': return SettingsService.getShowFavoriteTracks();
case 'favorite-playlists': return SettingsService.getShowFavoritePlaylists();
case 'favorite-radio-stations': return SettingsService.getShowFavoriteRadioStations();
case 'favorite-podcasts': return SettingsService.getShowFavoritePodcasts();
default: return false;
}
}
/// Build content for a specific home row.
Future<List<MediaItem>> _autoBuildHomeRowContent(
MusicAssistantProvider provider, String rowId) async {
switch (rowId) {
case 'recent-albums':
var albums = await provider.getRecentAlbumsWithCache();
if (albums.isEmpty) albums = SyncService.instance.cachedAlbums.take(20).toList();
return albums.take(20).map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'discover-artists':
var artists = await provider.getDiscoverArtistsWithCache();
if (artists.isEmpty) artists = SyncService.instance.cachedArtists.take(10).toList();
return artists.take(10).map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'discover-albums':
final albums = await provider.getDiscoverAlbumsWithCache();
return albums.take(20).map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'continue-listening':
final books = await provider.getInProgressAudiobooksWithCache();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name, artist: b.authorsString,
artUri: _autoArtUri(provider, b), playable: true,
)).toList();
case 'discover-audiobooks':
final books = await provider.getDiscoverAudiobooksWithCache();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name, artist: b.authorsString,
artUri: _autoArtUri(provider, b), playable: true,
)).toList();
case 'discover-series':
return _autoBuildAudiobookSeriesList(provider);
case 'favorite-albums':
final albums = await provider.getFavoriteAlbums();
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'favorite-artists':
final artists = await provider.getFavoriteArtists();
return artists.map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'favorite-tracks':
final tracks = await provider.getFavoriteTracks();
const ctxKey = 'favs||';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
case 'favorite-playlists':
final playlists = await provider.getFavoritePlaylists();
return playlists.map((p) => MediaItem(
id: 'playlist|${p.provider}|${p.itemId}',
title: p.name, artist: p.owner,
artUri: _autoArtUri(provider, p), playable: false,
)).toList();
case 'favorite-radio-stations':
final stations = await provider.getFavoriteRadioStations();
return stations.map((s) => MediaItem(
id: 'radio|${s.provider}|${s.itemId}',
title: s.name,
artUri: _autoArtUri(provider, s), playable: true,
)).toList();
case 'favorite-podcasts':
// Use cached podcasts filtered by favorite
final podcasts = SyncService.instance.cachedPodcasts
.where((p) => p.favorite == true).toList();
return podcasts.map((p) => MediaItem(
id: 'podcast|${p.provider}|${p.itemId}',
title: p.name,
artUri: _autoArtUri(provider, p), playable: false,
)).toList();
default:
return [];
}
}
List<MediaItem> _autoBuildMusicCategories() {
return [
MediaItem(id: _autoIdArtists, title: 'Artists', playable: false,
artUri: _iconArtist, extras: _gridHints),
MediaItem(id: _autoIdAlbums, title: 'Albums', playable: false,
artUri: _iconAlbum, extras: _gridHints),
MediaItem(id: _autoIdPlaylists, title: 'Playlists', playable: false,
artUri: _iconPlaylist, extras: _gridHints),
MediaItem(id: _autoIdFavorites, title: 'Favourites', playable: false,
artUri: _iconFavorite),
];
}
List<MediaItem> _autoBuildAudiobookCategories() {
return [
MediaItem(id: _autoIdAbAuthors, title: 'Authors', playable: false,
artUri: _iconArtist),
MediaItem(id: _autoIdAbBooks, title: 'Books', playable: false,
artUri: _iconBook, extras: _gridHints),
MediaItem(id: _autoIdAbSeries, title: 'Series', playable: false,
artUri: _iconBook, extras: _gridHints),
];
}
// --- Music builders ---
Future<List<MediaItem>> _autoBuildFavorites(
MusicAssistantProvider provider) async {
final tracks = await provider.getFavoriteTracks();
_logger.log('AndroidAuto: Favorites returned ${tracks.length} tracks');
const ctxKey = 'favs||';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
List<MediaItem> _autoBuildPlaylistList(MusicAssistantProvider provider) {
final playlists = SyncService.instance.cachedPlaylists;
_logger.log('AndroidAuto: Playlists: ${playlists.length}');
return playlists.map((p) => MediaItem(
id: 'playlist|${p.provider}|${p.itemId}',
title: p.name,
artist: p.owner,
artUri: _autoArtUri(provider, p),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildPlaylistTracks(
MusicAssistantProvider provider, String plProvider, String plItemId) async {
final tracks =
await provider.getPlaylistTracksWithCache(plProvider, plItemId);
final ctxKey = 'plist|$plProvider|$plItemId';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
List<MediaItem> _autoBuildArtistList(MusicAssistantProvider provider) {
final artists = SyncService.instance.cachedArtists;
_logger.log('AndroidAuto: Artists: ${artists.length}');
return artists.map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildArtistAlbums(
MusicAssistantProvider provider, String artistName) async {
var albums = await provider.getArtistAlbumsWithCache(artistName);
if (albums.isEmpty) {
// Fallback to library if API unavailable
albums = provider.getArtistAlbumsFromLibrary(artistName);
}
_logger.log('AndroidAuto: Artist "$artistName" albums: ${albums.length}');
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name,
artist: a.artistsString,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
List<MediaItem> _autoBuildAlbumList(MusicAssistantProvider provider) {
final albums = SyncService.instance.cachedAlbums;
_logger.log('AndroidAuto: Albums: ${albums.length}');
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name,
artist: a.artistsString,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildAlbumTracks(
MusicAssistantProvider provider, String alProvider, String alItemId) async {
final tracks =
await provider.getAlbumTracksWithCache(alProvider, alItemId);
_logger.log('AndroidAuto: Album $alProvider/$alItemId tracks: ${tracks.length}');
final ctxKey = 'album|$alProvider|$alItemId';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
// --- Audiobook builders ---
List<MediaItem> _autoBuildAudiobookAuthorList(
MusicAssistantProvider provider) {
final books = SyncService.instance.cachedAudiobooks;
final seen = <String>{};
final items = <MediaItem>[];
for (final b in books) {
final author = b.authorsString;
if (seen.add(author)) {
items.add(MediaItem(
id: 'ab_author|$author',
title: author,
artUri: _autoArtUri(provider, b),
playable: false,
));
}
}
_logger.log('AndroidAuto: Audiobook authors: ${items.length}');
return items;
}
List<MediaItem> _autoBuildAuthorAudiobooks(
MusicAssistantProvider provider, String authorName) {
final books = SyncService.instance.cachedAudiobooks
.where((b) => b.authorsString == authorName)
.toList();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
Future<List<MediaItem>> _autoBuildAudiobookList(MusicAssistantProvider provider) async {
var books = SyncService.instance.cachedAudiobooks;
if (books.isEmpty) {
// Sync cache may not be loaded yet — wait for it
_logger.log('AndroidAuto: cachedAudiobooks empty, loading from cache');
await SyncService.instance.loadFromCache();
books = SyncService.instance.cachedAudiobooks;
}
_logger.log('AndroidAuto: Books: ${books.length}');
// Sort alphabetically and cap to avoid overwhelming Android Auto
books.sort((a, b) => a.name.compareTo(b.name));
return books.take(500).map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
Future<List<MediaItem>> _autoBuildAudiobookSeriesList(
MusicAssistantProvider provider) async {
final series = await provider.getDiscoverSeriesWithCache();
_logger.log('AndroidAuto: Audiobook series: ${series.length}');
final items = <MediaItem>[];
for (final s in series) {
// Use first book's artwork instead of series mosaic thumbnail
Uri? artUri;
var cachedBooks = provider.getCachedSeriesAudiobooks(s.id);
if (cachedBooks == null || cachedBooks.isEmpty) {
// Fetch books for this series (result is cached for future use)
try {
cachedBooks = await provider.getSeriesAudiobooksWithCache(s.id);
} catch (_) {}
}
if (cachedBooks != null && cachedBooks.isNotEmpty) {
artUri = _autoArtUri(provider, cachedBooks.first);
}
items.add(MediaItem(
id: 'ab_series|${s.id}',
title: s.name,
artUri: artUri,
playable: false,
));
}
return items;
}
Future<List<MediaItem>> _autoBuildSeriesAudiobooks(
MusicAssistantProvider provider, String seriesPath) async {
final books = await provider.getSeriesAudiobooksWithCache(seriesPath);
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
// --- Podcast builders ---
List<MediaItem> _autoBuildPodcastList(MusicAssistantProvider provider) {
final podcasts = SyncService.instance.cachedPodcasts;
_logger.log('AndroidAuto: Podcasts: ${podcasts.length}');
return podcasts.map((p) => MediaItem(
id: 'podcast|${p.provider}|${p.itemId}',
title: p.name,
artUri: _autoArtUri(provider, p),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildPodcastEpisodes(
MusicAssistantProvider provider, String podProvider, String podItemId) async {
final episodes = await provider.getPodcastEpisodesWithCache(
podItemId,
provider: podProvider,
);
return episodes.map((e) => MediaItem(
id: 'podcast_ep|${e.provider}|${e.itemId}',
title: e.name,
duration: e.duration,
artUri: _autoArtUri(provider, e),
playable: true,
)).toList();
}
// --- Radio builder ---
Future<List<MediaItem>> _autoBuildRadioList(MusicAssistantProvider provider) async {
// Radio stations are lazily loaded — trigger load if empty
if (provider.radioStations.isEmpty) {
_logger.log('AndroidAuto: Radio empty, loading stations');
await provider.loadRadioStations();
}
final stations = provider.radioStations;
_logger.log('AndroidAuto: Radio stations: ${stations.length}');
return stations.map((s) => MediaItem(
id: 'radio|${s.provider}|${s.itemId}',
title: s.name,
artUri: _autoArtUri(provider, s),
playable: true,
)).toList();
}
// --- Helpers ---
MediaItem _autoTrackItem(
MusicAssistantProvider provider, ma.Track t, String ctxKey) {
return MediaItem(
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
title: t.name,
artist: t.artistsString,
album: t.album?.name,
duration: t.duration,
artUri: _autoArtUri(provider, t),
playable: true,
);
}
Uri? _autoArtUri(MusicAssistantProvider provider, ma.MediaItem item) {
final url = provider.getImageUrl(item, size: 256);
return url != null ? Uri.tryParse(url) : null;
}
// ---------------------------------------------------------------------------
/// Dispose of resources and cancel all subscriptions
Future<void> dispose() async {
await _interruptionSubscription?.cancel();
+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)
+43 -58
View File
@@ -251,7 +251,7 @@ class MusicAssistantAPI {
void _handleMessage(dynamic message) {
try {
final data = jsonDecode(message as String) as Map<String, dynamic>;
_logger.log('Received message: ${data.keys}');
_logger.debug('Received message: ${data.keys}');
// Check for server info message (first message on connect)
if (data.containsKey('server_version')) {
@@ -1621,36 +1621,28 @@ class MusicAssistantAPI {
}
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
return await RetryHelper.retryNetwork(
operation: () async {
try {
final response = await _sendCommand(
'music/albums/album_tracks',
args: {
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
try {
final response = await _sendCommand(
'music/albums/album_tracks',
args: {
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('No result for album tracks');
return <Track>[];
}
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('No result for album tracks');
return <Track>[];
}
return items
.map((item) => Track.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting album tracks: $e');
return <Track>[];
}
},
).catchError((e) {
_logger.log('Error getting album tracks after retries: $e');
return <Track>[];
});
return items
.map((item) => Track.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting album tracks: $e');
rethrow;
}
}
/// Get playlists
@@ -1711,35 +1703,28 @@ class MusicAssistantAPI {
/// Get playlist tracks
Future<List<Track>> getPlaylistTracks(String provider, String itemId) async {
return await RetryHelper.retryNetwork(
operation: () async {
try {
final response = await _sendCommand(
'music/playlists/playlist_tracks',
args: {
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
try {
final response = await _sendCommand(
'music/playlists/playlist_tracks',
args: {
'provider_instance_id_or_domain': provider,
'item_id': itemId,
},
);
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('No result for playlist tracks');
return <Track>[];
}
final items = response['result'] as List<dynamic>?;
if (items == null) {
_logger.log('No result for playlist tracks');
return <Track>[];
}
return items
.map((item) => Track.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting playlist tracks: $e');
return <Track>[];
}
},
).catchError((e) {
_logger.log('Error getting playlist tracks after retries: $e');
return <Track>[];
});
return items
.map((item) => Track.fromJson(item as Map<String, dynamic>))
.toList();
} catch (e) {
_logger.log('Error getting playlist tracks: $e');
rethrow;
}
}
// Favorites
@@ -1996,7 +1981,7 @@ class MusicAssistantAPI {
currentIndex = queueResult['current_index'] as int?;
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
repeatMode = queueResult['repeat_mode'] as String?;
_logger.log('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
_logger.debug('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
final currentItemName = currentItemData?['name'] as String?;
@@ -2032,7 +2017,7 @@ class MusicAssistantAPI {
}
if (items.isEmpty) {
_logger.log('⚠️ Queue is empty or all items failed to parse');
_logger.debug('⚠️ Queue is empty or all items failed to parse');
return null;
}
+12 -1
View File
@@ -88,6 +88,9 @@ class PcmAudioPlayer {
// Each chunk is ~4KB, so max ~500 chunks
static const int _maxBufferChunks = 500;
// Software volume gain (0.0 = silent, 1.0 = full volume)
double _volumeGain = 1.0;
// Stats
int _framesPlayed = 0;
int _bytesPlayed = 0;
@@ -113,6 +116,11 @@ class PcmAudioPlayer {
_state == PcmPlayerState.resuming ||
_state == PcmPlayerState.stopping;
/// Set software volume gain (0.0 = silent, 1.0 = full). Applied to PCM samples at feed time.
void setVolumeGain(double gain) {
_volumeGain = gain.clamp(0.0, 1.0);
}
/// Check if feeding should be blocked
bool get _shouldBlockFeeding => isTransitioning || _state == PcmPlayerState.paused;
@@ -360,7 +368,10 @@ class PcmAudioPlayer {
// Convert Uint8List (raw bytes) to Int16 samples
// Sendspin sends 16-bit little-endian PCM
final samples = _bytesToInt16List(chunk);
final rawSamples = _bytesToInt16List(chunk);
final samples = _volumeGain == 1.0
? rawSamples
: rawSamples.map((s) => (s * _volumeGain).round().clamp(-32768, 32767)).toList();
// Check state again before the async feed call
if (samples.isNotEmpty && !_shouldBlockFeeding) {
+9 -9
View File
@@ -100,7 +100,7 @@ class PositionTracker {
Duration? duration,
double? serverTimestamp,
}) {
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
_logger.debug('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
final bool playerChanged = _playerId != playerId;
final bool playStateChanged = _isPlaying != isPlaying;
final bool durationChanged = duration != null && _duration != duration;
@@ -179,11 +179,11 @@ class PositionTracker {
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
: anchorPos; // Use server position for other cases
_logger.log('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_logger.debug('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
_anchorPosition = newAnchorPos;
_anchorTime = DateTime.now();
} else if (positionDiff > 2) {
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
_logger.debug('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
}
_isPlaying = isPlaying;
@@ -227,7 +227,7 @@ class PositionTracker {
/// Clear tracker state (e.g., when disconnecting)
void clear() {
_logger.log('⏱️ PositionTracker.clear() called');
_logger.debug('⏱️ PositionTracker.clear() called');
_stopInterpolationTimer();
_playerId = null;
_isPlaying = false;
@@ -238,17 +238,17 @@ class PositionTracker {
void _startInterpolationTimer() {
if (_interpolationTimer != null) {
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
_logger.debug('⏱️ PositionTracker: Timer already running, skipping start');
return;
}
_logger.log('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_logger.debug('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
// Log every 4 ticks (1 second) if no emission happened
if (timer.tick % 4 == 0) {
final pos = currentPosition;
if (pos.inSeconds == _lastEmittedSeconds) {
_logger.log('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
_logger.debug('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
}
}
_emitPosition();
@@ -259,7 +259,7 @@ class PositionTracker {
}
void _stopInterpolationTimer() {
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_logger.debug('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
_interpolationTimer?.cancel();
_interpolationTimer = null;
}
@@ -271,7 +271,7 @@ class PositionTracker {
// Only emit if second changed (reduces unnecessary updates)
if (seconds != _lastEmittedSeconds) {
_lastEmittedSeconds = seconds;
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_logger.debug('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
_positionController.add(pos);
}
}
+20 -2
View File
@@ -249,7 +249,7 @@ class SendspinService {
'name': _playerName,
'version': 1, // Protocol version (integer)
'supported_roles': ['player@v1'],
'player_support': {
'player@v1_support': {
'supported_formats': [
{
'codec': 'pcm',
@@ -427,7 +427,25 @@ class SendspinService {
}
break;
case 'server/command':
final cmdPayload = data['payload'] as Map<String, dynamic>?;
final playerCmd = cmdPayload?['player'] as Map<String, dynamic>?;
final command = playerCmd?['command'] as String?;
if (command == 'volume') {
final level = playerCmd?['volume'] as int?;
if (level != null) {
_volume = level;
onVolume?.call(level);
}
} else if (command == 'mute') {
final muted = playerCmd?['mute'] as bool? ?? false;
_isMuted = muted;
onVolume?.call(muted ? 0 : _volume);
}
break;
case 'volume':
// Legacy protocol - kept for compatibility
final level = data['level'] as int?;
if (level != null) {
_volume = level;
@@ -523,7 +541,7 @@ class SendspinService {
try {
final json = jsonEncode(message);
_logger.log('Sendspin: Sending message: ${message['type']}');
_logger.debug('Sendspin: Sending message: ${message['type']}');
_channel!.sink.add(json);
} catch (e) {
_logger.log('Sendspin: Error sending message: $e');
+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(
+1 -1
View File
@@ -657,7 +657,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_logger.log('📊 Subscribing to positionStream for progress bar');
_positionSubscription = maProvider.positionTracker.positionStream.listen((position) {
if (!mounted) return;
_logger.log('📊 Progress received position: ${position.inSeconds}s');
_logger.debug('📊 Progress received position: ${position.inSeconds}s');
_progressNotifier.value = position.inSeconds;
});
}
+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
+21 -17
View File
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show GlobalPlayerOverlay, BottomSpacing;
import '../theme/theme_provider.dart';
import '../l10n/app_localizations.dart';
import '../services/debug_logger.dart';
@@ -500,39 +501,42 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
final screenSize = MediaQuery.of(context).size;
final viewPadding = MediaQuery.of(context).viewPadding;
// Account for mini player and bottom nav bar
final bottomInset = BottomSpacing.withMiniPlayer + viewPadding.bottom;
// Account for mini player and bottom nav bar.
// On detail screens the nav bar hides and mini player drops to bottom edge.
final isOnDetailScreen = context.read<ThemeProvider>().isOnDetailScreen;
final bottomInset = isOnDetailScreen
? BottomSpacing.miniPlayerHeight + 22.0 + viewPadding.bottom
: BottomSpacing.withMiniPlayer + viewPadding.bottom;
// Position near the tap, but keep on screen
double left = widget.position.dx - menuWidth / 2;
double top = widget.position.dy;
// Keep menu on screen horizontally
left = left.clamp(8.0, screenSize.width - menuWidth - 8);
// Estimate menu height based on content
double estimatedHeight;
double estimatedHeight = 8.0; // vertical padding (4 top + 4 bottom)
if (widget.mediaType == ContextMenuMediaType.podcastEpisode) {
// Episode menu: 4 items + divider = ~200px
estimatedHeight = 200.0;
estimatedHeight += 4 * 46.0; // 4 menu items
} else {
estimatedHeight = widget.showTopRow ? 280.0 : 180.0;
// Add height for sort section (header + items)
if (widget.showTopRow) estimatedHeight += 60.0; // icon button row
estimatedHeight += 3 * 46.0; // Play on, Start radio, Start radio on
if (widget.onToggleSort != null) {
// Podcast has 4 sort options, others have 2
estimatedHeight += widget.mediaType == ContextMenuMediaType.podcast ? 170.0 : 110.0;
estimatedHeight += 1.0 + 32.0; // divider + header
final sortCount = widget.mediaType == ContextMenuMediaType.podcast ? 4 : 2;
estimatedHeight += sortCount * 42.0; // sort items
}
// Add height for view section (divider + header + 3 items)
if (widget.onCycleView != null) {
estimatedHeight += 140.0;
estimatedHeight += 1.0 + 32.0; // divider + header
estimatedHeight += 3 * 42.0; // view mode items
}
}
// If menu would go off bottom (accounting for mini player), position above the tap point
if (top + estimatedHeight > screenSize.height - bottomInset) {
top = widget.position.dy - estimatedHeight;
}
top = top.clamp(viewPadding.top + 8, screenSize.height - estimatedHeight - bottomInset);
// Keep menu below the tap point but above the mini player
final top = widget.position.dy.clamp(
viewPadding.top + 8,
screenSize.height - estimatedHeight - bottomInset,
);
return Stack(
children: [
+8 -3
View File
@@ -72,6 +72,7 @@ class _VolumeControlState extends State<VolumeControl> {
setState(() {
_systemVolume = volume;
});
context.read<MusicAssistantProvider>().updateLocalPlayerVolume((volume * 100).round());
}
});
}
@@ -140,6 +141,7 @@ class _VolumeControlState extends State<VolumeControl> {
_systemVolume = newVolume / 100.0;
});
}
maProvider.updateLocalPlayerVolume(newVolume);
} catch (e) {
_logger.log('Volume: Error setting system volume - $e');
}
@@ -163,6 +165,7 @@ class _VolumeControlState extends State<VolumeControl> {
if (_isLocalPlayer) {
FlutterVolumeController.setVolume(volume);
maProvider.updateLocalPlayerVolume(volumeLevel);
} else {
maProvider.setVolume(playerId, volumeLevel);
}
@@ -171,8 +174,9 @@ class _VolumeControlState extends State<VolumeControl> {
@override
Widget build(BuildContext context) {
// Use select to only rebuild when selectedPlayer changes (not on every provider update)
// Use select to only rebuild when selectedPlayer or localPlayerVolume changes
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
final maLocalVolume = context.select<MusicAssistantProvider, int>((p) => p.localPlayerVolume);
if (player == null) {
return const SizedBox.shrink();
@@ -182,9 +186,9 @@ class _VolumeControlState extends State<VolumeControl> {
// Use pending volume during drag or button tap to prevent stale state issues
final currentVolume = (_isDragging || _showButtonIndicator)
? (_pendingVolume ?? (_isLocalPlayer ? (_systemVolume ?? 0.5) : player.volume.toDouble() / 100.0))
? (_pendingVolume ?? (_isLocalPlayer ? maLocalVolume / 100.0 : player.volume.toDouble() / 100.0))
: _isLocalPlayer
? (_systemVolume ?? 0.5)
? maLocalVolume / 100.0
: player.volume.toDouble() / 100.0;
// Use accent color if provided, otherwise fall back to theme primary
@@ -298,6 +302,7 @@ class _VolumeControlState extends State<VolumeControl> {
if (_isLocalPlayer) {
FlutterVolumeController.setVolume(finalVolume);
_systemVolume = finalVolume;
context.read<MusicAssistantProvider>().updateLocalPlayerVolume(volumeLevel);
} else {
context.read<MusicAssistantProvider>().setVolume(player.playerId, volumeLevel);
}
+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.5+57
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -27,6 +27,7 @@ dependencies:
web_socket_channel: ^2.4.0
http: ^1.1.0
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.3.1
# Storage
shared_preferences: ^2.2.2
@@ -49,6 +50,7 @@ dependencies:
audio_service: ^0.18.12
audio_session: ^0.1.18
flutter_volume_controller: ^1.3.2
rxdart: ^0.28.0
# Raw PCM Audio Playback (for Sendspin streaming)
flutter_pcm_sound: ^3.3.3