Compare commits
31
Commits
v3.0.3-beta
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7fe6dfbbe | ||
|
|
cb07e55d69 | ||
|
|
cf0336e99d | ||
|
|
73fd1dbdcd | ||
|
|
2eb22cf524 | ||
|
|
3fe7a5800a | ||
|
|
972690b894 | ||
|
|
0e5679262c | ||
|
|
67bae29f9d | ||
|
|
4f7fbe92d9 | ||
|
|
0c2853b3df | ||
|
|
0d0ca75a6a | ||
|
|
661888ec37 | ||
|
|
c9b03bf045 | ||
|
|
f287fa2819 | ||
|
|
e50ca040d8 | ||
|
|
c7773ead0c | ||
|
|
ba2928a847 | ||
|
|
51c94e40a3 | ||
|
|
e6b2eb48b8 | ||
|
|
fb867f7410 | ||
|
|
e91bdf2353 | ||
|
|
547319231b | ||
|
|
42dc7727a0 | ||
|
|
803cd5694c | ||
|
|
64dc18c0fe | ||
|
|
47b4e41252 | ||
|
|
6920ea0bbd | ||
|
|
d1d29486d6 | ||
|
|
e84515664a | ||
|
|
57cb13b26d |
@@ -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
|
||||
@@ -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
|
||||
@@ -1,7 +1,8 @@
|
||||
<div align="center">
|
||||
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
|
||||
|
||||
[](https://github.com/CollotsSpot/Ensemble/releases)
|
||||
[](https://github.com/CollotsSpot/Ensemble/releases)
|
||||
[](https://github.com/CollotsSpot/Ensemble/releases/latest)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/sponsors/CollotsSpot)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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 '../..'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, _) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user