Compare commits
| 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 | ||
|
|
844bce2eea | ||
|
|
49ccbc618a | ||
|
|
90cb699566 | ||
|
|
92777197ac | ||
|
|
688b6354a8 | ||
|
|
d29ce362e7 | ||
|
|
0a385af168 | ||
|
|
c448c4ab19 | ||
|
|
ea7144d81d | ||
|
|
48afca040e | ||
|
|
c85f19d1d3 | ||
|
|
1f3a3b3c1d | ||
|
|
501fe0967a | ||
|
|
f17bfdfa55 | ||
|
|
be1d7c3b50 | ||
|
|
1d7cbdeeed | ||
|
|
d62e1169ea | ||
|
|
3970839133 | ||
|
|
8181e75a08 | ||
|
|
9600f4bcfe | ||
|
|
a2b8b1eeef | ||
|
|
0263c75804 | ||
|
|
469e77c572 | ||
|
|
577b03bbea | ||
|
|
7bbba90fff | ||
|
|
6f66a1ebff | ||
|
|
0ceab80a14 | ||
|
|
efb84a4e50 | ||
|
|
731f45bea9 | ||
|
|
32e61f109e | ||
|
|
cd9e82b1fd | ||
|
|
04322cf768 | ||
|
|
e6349c309b |
@@ -0,0 +1,3 @@
|
||||
# These are the funding models supported by Ensemble
|
||||
github: CollotsSpot
|
||||
custom: https://ko-fi.com/collotsspot
|
||||
@@ -0,0 +1,388 @@
|
||||
# Android Auto Implementation Plan for Ensemble
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document describes adding Android Auto support to Ensemble. When a user connects their Android phone to a car head unit, Android Auto will browse and play music through Ensemble without touching the phone.
|
||||
|
||||
### What we're building
|
||||
|
||||
- A browse hierarchy (Favorites, Playlists, Albums, Radio) accessible from the car screen
|
||||
- Playback from Android Auto routes through the builtin (local) player, which streams through MA as normal
|
||||
- Voice search support
|
||||
|
||||
### Why builtin player only
|
||||
|
||||
Android Auto requires local audio output. The builtin player plays audio through `just_audio` on the device, which the car receives over Bluetooth. Remote MA players are irrelevant in a car context.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
### Provider reference
|
||||
|
||||
`MassivAudioHandler` has no reference to `MusicAssistantProvider` today. The handler is created before the provider (in `main()` before `runApp`). Solution: add a `setProvider()` method and call it from `_MusicAssistantAppState.initState()`.
|
||||
|
||||
```
|
||||
main.dart
|
||||
audioHandler = await AudioService.init(...) // global, created first
|
||||
runApp(MusicAssistantApp())
|
||||
_MusicAssistantAppState.initState()
|
||||
_musicProvider = MusicAssistantProvider()
|
||||
audioHandler.setProvider(_musicProvider) // NEW
|
||||
```
|
||||
|
||||
### Browse → play call flow
|
||||
|
||||
```
|
||||
Android Auto MassivAudioHandler MusicAssistantProvider
|
||||
| | |
|
||||
getChildren('root') -----> | returns category items |
|
||||
getChildren('cat|playlists')-> | SyncService.cachedPlaylists |
|
||||
getChildren('playlist|lib|5')-> | provider.getPlaylistTracksWithCache()
|
||||
| populates _trackQueueCache |
|
||||
playFromMediaId('track|lib|7|plist|lib|5') -> |
|
||||
| lookup _trackQueueCache |
|
||||
| provider.playTracks(builtinId, tracks, startIndex: i)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Media ID Scheme
|
||||
|
||||
Separator: `|` (pipe). Provider names use underscores/colons, never pipes — safe.
|
||||
|
||||
| ID | Meaning |
|
||||
|----|---------|
|
||||
| `root` | Root (browsable) |
|
||||
| `recent` | Currently playing track (playable) |
|
||||
| `cat\|favorites` | Favorites category (browsable) |
|
||||
| `cat\|playlists` | Playlists category (browsable) |
|
||||
| `cat\|albums` | Albums category (browsable) |
|
||||
| `cat\|radio` | Radio stations category (browsable) |
|
||||
| `playlist\|{provider}\|{itemId}` | A playlist (browsable) |
|
||||
| `album\|{provider}\|{itemId}` | An album (browsable) |
|
||||
| `track\|{tProvider}\|{tItemId}\|{ctxType}\|{ctxProvider}\|{ctxId}` | A track in context (playable) |
|
||||
| `radio\|{provider}\|{itemId}` | A radio station (playable) |
|
||||
|
||||
The `ctxType|ctxProvider|ctxId` suffix in track IDs encodes the parent container. Context types: `plist`, `album`, `favs`, `search`.
|
||||
|
||||
**Track queue cache key** = `{ctxType}|{ctxProvider}|{ctxId}` (e.g. `plist|spotify|abc`). Stored when building children, looked up in `playFromMediaId`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Browse Hierarchy
|
||||
|
||||
```
|
||||
root
|
||||
├── recent (playable, shown if something is/was playing)
|
||||
├── cat|favorites (browsable → favorite tracks, all playable)
|
||||
├── cat|playlists (browsable → list of playlists, each browsable)
|
||||
│ └── playlist|{provider}|{id} → tracks (playable)
|
||||
├── cat|albums (browsable → list of albums, each browsable)
|
||||
│ └── album|{provider}|{id} → tracks (playable)
|
||||
└── cat|radio (browsable → radio stations, each playable)
|
||||
```
|
||||
|
||||
Artists are **not included**. Navigating Artist → Albums → Album → Tracks is four taps — too many while driving. Can be added later.
|
||||
|
||||
---
|
||||
|
||||
## 5. Track Queue Cache
|
||||
|
||||
Tapping a track in Android Auto should play the full album/playlist from that track, not just the single track. The handler caches track lists when building children:
|
||||
|
||||
```dart
|
||||
// Key: ctxKey e.g. "plist|spotify|abc" or "album|library|42" or "favs||"
|
||||
// Value: ordered list of tracks in that container
|
||||
final Map<String, List<ma.Track>> _trackQueueCache = {};
|
||||
```
|
||||
|
||||
When `getChildren('playlist|spotify|abc')` is called:
|
||||
1. Fetch tracks via `provider.getPlaylistTracksWithCache('spotify', 'abc')`
|
||||
2. Store `_trackQueueCache['plist|spotify|abc'] = tracks`
|
||||
3. Return each track as a playable `MediaItem` with id `track|{t.provider}|{t.itemId}|plist|spotify|abc`
|
||||
|
||||
When `playFromMediaId('track|lib|7|plist|spotify|abc')` is called:
|
||||
1. Parse ctxKey = `plist|spotify|abc`
|
||||
2. Look up `_trackQueueCache['plist|spotify|abc']`
|
||||
3. Find track index where `itemId == '7'`
|
||||
4. Call `provider.playTracks(builtinId, tracks, startIndex: index)`
|
||||
|
||||
---
|
||||
|
||||
## 6. Key Code for `massiv_audio_handler.dart`
|
||||
|
||||
### New imports
|
||||
|
||||
```dart
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../settings_service.dart';
|
||||
import '../sync_service.dart';
|
||||
import '../../models/media_item.dart' as ma;
|
||||
```
|
||||
|
||||
Note: `audio_service` exports its own `MediaItem`. Use `ma.MediaItem`, `ma.Track`, `ma.Album`, `ma.Playlist` for app models. The bare `MediaItem` refers to the audio_service type throughout new methods.
|
||||
|
||||
### New fields
|
||||
|
||||
```dart
|
||||
MusicAssistantProvider? _provider;
|
||||
final Map<String, ma.Track> _trackQueueCache = {}; // ctxKey -> tracks (see section 5)
|
||||
final Map<String, BehaviorSubject<Map<String, dynamic>>> _childrenSubjects = {};
|
||||
|
||||
void setProvider(MusicAssistantProvider provider) {
|
||||
_provider = provider;
|
||||
}
|
||||
```
|
||||
|
||||
### `getChildren`
|
||||
|
||||
```dart
|
||||
@override
|
||||
Future<List<MediaItem>> getChildren(String parentMediaId,
|
||||
[Map<String, dynamic>? options]) async {
|
||||
final provider = _provider;
|
||||
if (provider == null) return [];
|
||||
|
||||
switch (parentMediaId) {
|
||||
case AudioService.browsableRootId:
|
||||
return _buildRoot(provider);
|
||||
case 'cat|favorites':
|
||||
return _buildFavorites(provider);
|
||||
case 'cat|playlists':
|
||||
return _buildPlaylistList(provider);
|
||||
case 'cat|albums':
|
||||
return _buildAlbumList(provider);
|
||||
case 'cat|radio':
|
||||
return _buildRadioList(provider);
|
||||
default:
|
||||
if (parentMediaId.startsWith('playlist|')) {
|
||||
final parts = parentMediaId.split('|');
|
||||
if (parts.length >= 3) return _buildPlaylistTracks(provider, parts[1], parts[2]);
|
||||
}
|
||||
if (parentMediaId.startsWith('album|')) {
|
||||
final parts = parentMediaId.split('|');
|
||||
if (parts.length >= 3) return _buildAlbumTracks(provider, parts[1], parts[2]);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `playFromMediaId`
|
||||
|
||||
```dart
|
||||
@override
|
||||
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) async {
|
||||
final provider = _provider;
|
||||
if (provider == null) return;
|
||||
|
||||
final playerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (playerId == null) return;
|
||||
|
||||
_logger.log('AndroidAuto: playFromMediaId $mediaId');
|
||||
|
||||
try {
|
||||
if (mediaId == 'recent') {
|
||||
await provider.playPauseSelectedPlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaId.startsWith('radio|')) {
|
||||
final parts = mediaId.split('|'); // ['radio', provider, itemId]
|
||||
if (parts.length < 3) return;
|
||||
final station = provider.radioStations.firstWhere(
|
||||
(s) => s.provider == parts[1] && s.itemId == parts[2],
|
||||
orElse: () => throw Exception('Station not found: $mediaId'),
|
||||
);
|
||||
await provider.playRadio(playerId, station);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaId.startsWith('track|')) {
|
||||
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
|
||||
final parts = mediaId.split('|');
|
||||
if (parts.length < 6) return;
|
||||
final tProvider = parts[1];
|
||||
final tItemId = parts[2];
|
||||
final ctxKey = parts.sublist(3).join('|');
|
||||
|
||||
final trackList = _trackQueueCache[ctxKey];
|
||||
if (trackList == null || trackList.isEmpty) {
|
||||
// Fallback: play single track (minimal Track object)
|
||||
await provider.playTrack(playerId, ma.Track(itemId: tItemId, provider: tProvider, name: ''));
|
||||
return;
|
||||
}
|
||||
|
||||
final index = trackList.indexWhere((t) => t.provider == tProvider && t.itemId == tItemId);
|
||||
await provider.playTracks(playerId, trackList, startIndex: index < 0 ? 0 : index);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('AndroidAuto: playFromMediaId error: $e');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `search`
|
||||
|
||||
```dart
|
||||
@override
|
||||
Future<List<MediaItem>> search(String query, [Map<String, dynamic>? extras]) async {
|
||||
final provider = _provider;
|
||||
if (provider == null || query.trim().isEmpty) return [];
|
||||
|
||||
try {
|
||||
final results = await provider.searchWithCache(query);
|
||||
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
|
||||
const ctxKey = 'search||';
|
||||
_trackQueueCache[ctxKey] = tracks;
|
||||
|
||||
return tracks.map((t) => MediaItem(
|
||||
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
|
||||
title: t.name,
|
||||
artist: t.artistsString,
|
||||
album: t.album?.name,
|
||||
duration: t.duration,
|
||||
artUri: _artUri(provider, t),
|
||||
playable: true,
|
||||
)).toList();
|
||||
} catch (e) {
|
||||
_logger.log('AndroidAuto: search error: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `subscribeToChildren`
|
||||
|
||||
```dart
|
||||
@override
|
||||
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
|
||||
_childrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
|
||||
return _childrenSubjects[parentMediaId]!.stream;
|
||||
}
|
||||
```
|
||||
|
||||
### `_artUri` helper
|
||||
|
||||
```dart
|
||||
Uri? _artUri(MusicAssistantProvider provider, ma.MediaItem item) {
|
||||
final url = provider.getImageUrl(item, size: 256);
|
||||
return url != null ? Uri.tryParse(url) : null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Files to Change
|
||||
|
||||
### `pubspec.yaml`
|
||||
Add `rxdart: ^0.28.0` as direct dependency. The `^0.28.0` constraint matches the already-resolved transitive version so no upgrade occurs.
|
||||
|
||||
### `android/app/build.gradle`
|
||||
Change:
|
||||
```groovy
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
```
|
||||
To:
|
||||
```groovy
|
||||
minSdkVersion 23
|
||||
```
|
||||
Android Auto requires API 23+. `flutter.minSdkVersion` resolves to 21 — too low.
|
||||
|
||||
### `android/app/src/main/AndroidManifest.xml`
|
||||
Add inside `<application>` tag:
|
||||
```xml
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
```
|
||||
The `MediaBrowserService` intent filter on `AudioService` is already present — no changes needed there.
|
||||
|
||||
### `android/app/src/main/res/xml/automotive_app_desc.xml` (NEW)
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
```
|
||||
|
||||
### `lib/main.dart`
|
||||
In `_MusicAssistantAppState.initState()`, after creating `_musicProvider`:
|
||||
```dart
|
||||
audioHandler.setProvider(_musicProvider);
|
||||
```
|
||||
|
||||
### `lib/services/audio/massiv_audio_handler.dart`
|
||||
Add ~200 lines. New imports, fields, `setProvider()`, `getChildren()`, `playFromMediaId()`, `search()`, `subscribeToChildren()`, and private builder helpers. All existing code is untouched.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing with DHU (No Car Required)
|
||||
|
||||
### Install DHU
|
||||
In Android Studio: SDK Manager → SDK Tools → Android Auto Desktop Head Unit Emulator.
|
||||
|
||||
Or via command line:
|
||||
```bash
|
||||
sdkmanager "extras;google;auto"
|
||||
```
|
||||
|
||||
### Enable Android Auto developer mode on phone
|
||||
1. Settings → Apps → Android Auto → tap version number 10 times
|
||||
2. In Android Auto developer settings: enable "Unknown sources"
|
||||
|
||||
### Run the test session
|
||||
```bash
|
||||
# Terminal 1: forward the port
|
||||
adb forward tcp:5277 tcp:5277
|
||||
|
||||
# Terminal 2: start the DHU
|
||||
$ANDROID_SDK_ROOT/extras/google/auto/desktop-head-unit
|
||||
```
|
||||
|
||||
In Android Auto developer settings on phone: tap "Start head unit server".
|
||||
|
||||
### Build and install
|
||||
```bash
|
||||
flutter build apk --debug
|
||||
adb install build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
Launch Ensemble on phone first (so audio service starts). Then tap the media icon in DHU — Ensemble should appear.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| App not visible in DHU | Missing `automotive_app_desc.xml` or wrong meta-data name | Check manifest and XML |
|
||||
| App visible, browse is empty | `setProvider` not yet called | Check `initState` wiring |
|
||||
| Build fails | `minSdkVersion` too low | Check `build.gradle` |
|
||||
| Track plays but wrong song | `_trackQueueCache` miss | Ensure `getChildren` ran before `playFromMediaId` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Order
|
||||
|
||||
Do these steps in order to test incrementally:
|
||||
|
||||
1. Android config files (`build.gradle`, `AndroidManifest.xml`, `automotive_app_desc.xml`) — verify app appears in DHU before writing Dart
|
||||
2. `pubspec.yaml` + `flutter pub get`
|
||||
3. `massiv_audio_handler.dart`: add `setProvider`, fields, `subscribeToChildren` — minimum to not crash
|
||||
4. `main.dart`: wire `setProvider`
|
||||
5. `massiv_audio_handler.dart`: `getChildren` with hardcoded root categories — verify tree appears in DHU
|
||||
6. `massiv_audio_handler.dart`: add all `_build*` helpers one by one, test each
|
||||
7. `massiv_audio_handler.dart`: add `playFromMediaId` — test track playback
|
||||
8. `massiv_audio_handler.dart`: add `search` — test voice search
|
||||
|
||||
---
|
||||
|
||||
## 10. Not In Scope
|
||||
|
||||
- **Artists browse** — too many taps for driving; albums alone suffice
|
||||
- **Audiobooks/podcasts** — different UX needs; add later
|
||||
- **Queue management from Android Auto** — MA queue model is complex
|
||||
- **Push updates** — `notifyChildrenChanged` stubbed but not wired to sync events
|
||||
- **Android Automotive OS** — different platform, requires a separate flavor
|
||||
-391
@@ -1,391 +0,0 @@
|
||||
# Ensemble Codebase Intensive Audit Prompt
|
||||
|
||||
## Overview
|
||||
|
||||
You are tasked with conducting an intensive, comprehensive audit of the **Ensemble** Flutter application - a Music Assistant client for Android with multi-device synchronization, local playback, and Sendspin raw audio streaming capabilities.
|
||||
|
||||
**Codebase Statistics:**
|
||||
- 90 Dart files, ~40,583 lines of code
|
||||
- 18 screens, 27 services, 6 providers
|
||||
- Architecture: Provider pattern with ChangeNotifier
|
||||
- Audio: just_audio + flutter_pcm_sound + audio_service
|
||||
- Networking: WebSocket (JSON-RPC) + HTTP + Sendspin binary protocol
|
||||
|
||||
---
|
||||
|
||||
## Audit Scope
|
||||
|
||||
Perform a thorough audit covering ALL of the following areas. For each issue found, provide:
|
||||
1. **File path and line number(s)**
|
||||
2. **Severity**: CRITICAL / HIGH / MEDIUM / LOW
|
||||
3. **Description** of the issue
|
||||
4. **Impact** if left unfixed
|
||||
5. **Recommended fix** with code example where applicable
|
||||
|
||||
---
|
||||
|
||||
## SECTION 1: SECURITY AUDIT
|
||||
|
||||
### 1.1 Credential Storage (CRITICAL PRIORITY)
|
||||
Audit `/lib/services/settings_service.dart` for:
|
||||
- [ ] Plaintext password storage in SharedPreferences (lines 209-222)
|
||||
- [ ] Auth tokens stored without encryption
|
||||
- [ ] Serialized credentials in JSON format (lines 169-190)
|
||||
|
||||
**Expected Finding:** Credentials stored insecurely. Recommend flutter_secure_storage.
|
||||
|
||||
### 1.2 Certificate Validation
|
||||
Audit `/lib/services/auth/auth_manager.dart` for:
|
||||
- [ ] HTTPS redirect handling for local IPs
|
||||
- [ ] Certificate validation disabled scenarios
|
||||
- [ ] MITM vulnerability exposure
|
||||
|
||||
### 1.3 Authentication Strategies
|
||||
Audit all files in `/lib/services/auth/`:
|
||||
- [ ] `basic_auth_strategy.dart` - Base64 encoding (not encryption)
|
||||
- [ ] `authelia_strategy.dart` - Session cookie handling, timing attack (line 159)
|
||||
- [ ] `ma_auth_strategy.dart` - Token refresh mechanism
|
||||
- [ ] `no_auth_strategy.dart` - Public server exposure
|
||||
|
||||
### 1.4 Input Validation
|
||||
Audit `/lib/services/music_assistant_api.dart`:
|
||||
- [ ] URI construction without validation (lines 1346-1360)
|
||||
- [ ] Provider/itemId injection risks
|
||||
- [ ] WebSocket message validation
|
||||
|
||||
### 1.5 Information Disclosure
|
||||
- [ ] Debug logs exposing sensitive data (check all `_logger.log()` calls)
|
||||
- [ ] Server version exposure via `server_info`
|
||||
- [ ] Error messages leaking technical details
|
||||
|
||||
---
|
||||
|
||||
## SECTION 2: MEMORY MANAGEMENT AUDIT
|
||||
|
||||
### 2.1 Static Memory Leaks
|
||||
Audit `/lib/models/player.dart`:
|
||||
- [ ] `_playerCreationTimes` static map (lines 96-144) - grows unbounded
|
||||
- [ ] Cleanup only removes 50 of 100+ entries
|
||||
|
||||
### 2.2 Unbounded Caches
|
||||
Audit `/lib/services/cache_service.dart`:
|
||||
- [ ] `_albumTracksCache` - no size limit
|
||||
- [ ] `_artistAlbumsCache` - no TTL expiration
|
||||
- [ ] `_searchCache` - never evicted
|
||||
- [ ] `_playerTrackCache` - only cleared on disconnect
|
||||
|
||||
### 2.3 Stream/Timer Cleanup
|
||||
Audit `/lib/providers/music_assistant_provider.dart`:
|
||||
- [ ] `_playerStateTimer` disposal (line 4435-4445)
|
||||
- [ ] `_notificationPositionTimer` multiple creation paths (lines 2925-2940)
|
||||
- [ ] `_localPlayerEventSubscription` cleanup
|
||||
- [ ] `_playerUpdatedEventSubscription` cleanup
|
||||
- [ ] `_playerAddedEventSubscription` cleanup
|
||||
|
||||
Audit `/lib/services/hardware_volume_service.dart`:
|
||||
- [ ] `_volumeUpController` never closed (lines 74-85)
|
||||
- [ ] `_volumeDownController` never closed
|
||||
|
||||
### 2.4 Stream Subscription Leaks
|
||||
Audit `/lib/providers/music_assistant_provider.dart`:
|
||||
- [ ] `_api!.connectionState.listen()` - subscription not stored
|
||||
- [ ] Old subscriptions not cancelled before new API instance
|
||||
|
||||
---
|
||||
|
||||
## SECTION 3: RACE CONDITION AUDIT
|
||||
|
||||
### 3.1 Player State Dual Updates
|
||||
Audit `/lib/providers/music_assistant_provider.dart`:
|
||||
- [ ] Timer polling (every 5s) vs WebSocket events racing
|
||||
- [ ] `_updatePlayerState()` called from both paths
|
||||
- [ ] No mutex/lock protection
|
||||
|
||||
### 3.2 Async Method Reentrancy
|
||||
Audit `selectPlayer()` method:
|
||||
- [ ] `async` method without guard against concurrent calls
|
||||
- [ ] `_selectedPlayer` modified before and after `await`
|
||||
- [ ] State could change during await
|
||||
|
||||
### 3.3 Fire-and-Forget Operations
|
||||
Search for `unawaited(` and `() async {` patterns:
|
||||
- [ ] `_persistPlaybackState()` - database save not awaited
|
||||
- [ ] Pause operations fire-and-forget (lines 3876-3894)
|
||||
- [ ] Preload operations (line 2707)
|
||||
|
||||
### 3.4 PCM Player State Machine
|
||||
Audit `/lib/services/pcm_audio_player.dart`:
|
||||
- [ ] 9-state machine with complex transitions (lines 32-37)
|
||||
- [ ] Auto-recovery race condition (lines 215-229)
|
||||
- [ ] Feed loop during pause transition (lines 174-179)
|
||||
|
||||
---
|
||||
|
||||
## SECTION 4: ERROR HANDLING AUDIT
|
||||
|
||||
### 4.1 Silent Exception Handlers (CRITICAL)
|
||||
Find all instances of:
|
||||
```dart
|
||||
} catch (e) {}
|
||||
} catch (_) {}
|
||||
```
|
||||
|
||||
Known locations:
|
||||
- [ ] `music_assistant_provider.dart` (multiple)
|
||||
- [ ] `player_provider.dart` (3 instances)
|
||||
- [ ] `recently_played_service.dart`
|
||||
|
||||
### 4.2 Missing Error Recovery
|
||||
- [ ] No exponential backoff on connection retry
|
||||
- [ ] No retry logic for failed API calls
|
||||
- [ ] WebSocket timeout handling (30 seconds - too long?)
|
||||
|
||||
### 4.3 Null Safety Violations
|
||||
Find all `!` (null assertion) operators:
|
||||
- [ ] `search_screen.dart` (20+ instances)
|
||||
- [ ] `models/media_item.dart`
|
||||
- [ ] `models/player.dart`
|
||||
- [ ] `item.mediaItem! as Artist` patterns
|
||||
|
||||
---
|
||||
|
||||
## SECTION 5: ARCHITECTURAL ISSUES
|
||||
|
||||
### 5.1 God Class
|
||||
Audit `/lib/providers/music_assistant_provider.dart`:
|
||||
- [ ] 4,469 lines - violates Single Responsibility
|
||||
- [ ] 386 conditional branches
|
||||
- [ ] Mixing: connection, players, library, sync, audio, position tracking
|
||||
|
||||
**Recommend splitting into:**
|
||||
- ConnectionProvider (~500 LOC)
|
||||
- PlayerManager (~800 LOC)
|
||||
- LibraryProvider (~1000 LOC)
|
||||
- LocalPlaybackManager (~600 LOC)
|
||||
|
||||
### 5.2 Oversized Widget Files
|
||||
- [ ] `expandable_player.dart` - 2,485 lines (should be <500)
|
||||
- [ ] `global_player_overlay.dart` - 737 lines
|
||||
- [ ] `new_library_screen.dart` - 2,521 lines
|
||||
|
||||
### 5.3 Global Key Anti-Pattern
|
||||
Audit `/lib/widgets/global_player_overlay.dart`:
|
||||
- [ ] `globalPlayerKey` for state access
|
||||
- [ ] `_overlayStateKey` for overlay state
|
||||
- [ ] Should use Provider/context instead
|
||||
|
||||
### 5.4 Primitive Obsession
|
||||
- [ ] View modes as strings ('grid2', 'grid3', 'list')
|
||||
- [ ] Media types as strings ('track', 'album', 'artist')
|
||||
- [ ] Should be strongly-typed enums
|
||||
|
||||
---
|
||||
|
||||
## SECTION 6: CODE QUALITY ISSUES
|
||||
|
||||
### 6.1 Testing (CRITICAL)
|
||||
- [ ] **ZERO test files exist** - confirm this
|
||||
- [ ] No unit tests, widget tests, or integration tests
|
||||
- [ ] flutter_test dependency exists but unused
|
||||
|
||||
### 6.2 Hardcoded Values
|
||||
Find and document all:
|
||||
- [ ] Color hex codes (50+ instances of `Color(0xFF...`)
|
||||
- [ ] Duration values not using `Timings` constants
|
||||
- [ ] Magic numbers (16, 4, 5, 100, 50)
|
||||
- [ ] String literals for media types
|
||||
|
||||
### 6.3 Mixed Async Patterns
|
||||
- [ ] `.then()` chains alongside async/await
|
||||
- [ ] `.whenComplete()` usage
|
||||
- [ ] Consolidate to async/await
|
||||
|
||||
### 6.4 Dead Code
|
||||
- [ ] `@Deprecated` methods still implemented
|
||||
- [ ] `animation_debugger.dart` - dev-only?
|
||||
- [ ] Unused imports
|
||||
|
||||
### 6.5 TODO Comments
|
||||
Document all actionable TODOs:
|
||||
- [ ] `queue_screen.dart:214` - queue item removal
|
||||
- [ ] `audiobook_detail_screen.dart:261,312` - progress sync
|
||||
- [ ] `music_assistant_provider.dart:793,799` - playlist modification
|
||||
|
||||
---
|
||||
|
||||
## SECTION 7: UI/UX AUDIT
|
||||
|
||||
### 7.1 Accessibility (CRITICAL)
|
||||
Audit for missing:
|
||||
- [ ] `Semantics` widgets on interactive elements
|
||||
- [ ] `Tooltip` on icon buttons
|
||||
- [ ] Screen reader support
|
||||
- [ ] Touch target sizes (minimum 48dp)
|
||||
- [ ] Color contrast validation (WCAG AA)
|
||||
|
||||
### 7.2 Performance Issues
|
||||
Audit `/lib/widgets/expandable_player.dart`:
|
||||
- [ ] No `RepaintBoundary` wrappers
|
||||
- [ ] 55+ `setState()` calls
|
||||
- [ ] 3 AnimationControllers with multiple listeners
|
||||
- [ ] Title height cache invalidation (lines 118+)
|
||||
|
||||
### 7.3 Double Opacity Bug
|
||||
Audit `/lib/widgets/player/player_controls.dart`:
|
||||
```dart
|
||||
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
||||
.withOpacity(expandedElementsOpacity),
|
||||
```
|
||||
- [ ] Double `.withOpacity()` application
|
||||
|
||||
### 7.4 Missing Features
|
||||
- [ ] No landscape orientation support
|
||||
- [ ] No tablet layout optimization
|
||||
- [ ] No loading skeleton animations
|
||||
- [ ] No haptic feedback (except one location)
|
||||
|
||||
---
|
||||
|
||||
## SECTION 8: AUDIO PLAYBACK AUDIT
|
||||
|
||||
### 8.1 Position Interpolation
|
||||
Audit `/lib/services/position_tracker.dart`:
|
||||
- [ ] No timeout on anchor updates - unbounded drift possible
|
||||
- [ ] Duration interpolation can exceed track duration
|
||||
- [ ] Stale timestamp handling (>30 seconds)
|
||||
|
||||
### 8.2 Sendspin Protocol
|
||||
Audit `/lib/services/sendspin_service.dart`:
|
||||
- [ ] Proxy auth retry without deduplication (lines 188-209)
|
||||
- [ ] WebSocket binary frame parsing
|
||||
- [ ] Clock synchronization accuracy
|
||||
|
||||
### 8.3 Audio Resource Cleanup
|
||||
Audit `/lib/services/audio/massiv_audio_handler.dart`:
|
||||
- [ ] Audio session interrupt listeners not disposed
|
||||
- [ ] Audio focus not explicitly released
|
||||
|
||||
### 8.4 Mixed Playback Modes
|
||||
- [ ] just_audio initialized but unused during Sendspin
|
||||
- [ ] Resource allocation for unused player
|
||||
- [ ] Potential audio focus conflicts
|
||||
|
||||
---
|
||||
|
||||
## SECTION 9: NETWORKING AUDIT
|
||||
|
||||
### 9.1 WebSocket Lifecycle
|
||||
Audit `/lib/services/music_assistant_api.dart`:
|
||||
- [ ] Heartbeat interval (30s) - appropriate?
|
||||
- [ ] Reconnection logic and timing
|
||||
- [ ] Pending request cleanup on disconnect
|
||||
|
||||
### 9.2 Caching Strategy
|
||||
- [ ] No incremental/delta sync (full library every 5 minutes)
|
||||
- [ ] Cache staleness not communicated to UI
|
||||
- [ ] No query result pagination (fetches up to 5000 items)
|
||||
|
||||
### 9.3 Rate Limiting
|
||||
- [ ] No client-side rate limiting
|
||||
- [ ] Search requests not throttled
|
||||
- [ ] Could overload server
|
||||
|
||||
### 9.4 Offline Support
|
||||
Audit `/lib/services/offline_action_queue.dart`:
|
||||
- [ ] Queue execution not atomic (lines 118-130)
|
||||
- [ ] Action could execute twice on crash
|
||||
- [ ] Queue stored unencrypted
|
||||
|
||||
---
|
||||
|
||||
## SECTION 10: DATABASE AUDIT
|
||||
|
||||
### 10.1 Schema Review
|
||||
Audit `/lib/database/database.dart`:
|
||||
- [ ] Table indexes for query performance
|
||||
- [ ] Foreign key constraints
|
||||
- [ ] Migration strategy
|
||||
|
||||
### 10.2 Query Patterns
|
||||
- [ ] N+1 query prevention
|
||||
- [ ] Transaction usage for atomic operations
|
||||
- [ ] Connection pooling
|
||||
|
||||
### 10.3 Data Consistency
|
||||
- [ ] Tier 1 (DB) vs Tier 2 (API sync) race conditions
|
||||
- [ ] User modifications during background sync
|
||||
- [ ] Conflict resolution strategy
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
After completing the audit, provide:
|
||||
|
||||
### 1. Executive Summary
|
||||
- Overall code quality score (1-10)
|
||||
- Top 5 critical issues requiring immediate attention
|
||||
- Estimated technical debt in developer-hours
|
||||
|
||||
### 2. Issue Tracker
|
||||
Create a prioritized list of all issues found:
|
||||
| ID | Severity | Category | File:Line | Description | Effort |
|
||||
|----|----------|----------|-----------|-------------|--------|
|
||||
|
||||
### 3. Recommended Roadmap
|
||||
Sprint-based plan to address findings:
|
||||
- Sprint 1 (Immediate): Security fixes, silent exception handlers
|
||||
- Sprint 2-3: God class decomposition, test infrastructure
|
||||
- Sprint 4-6: Memory leak fixes, accessibility
|
||||
- Long-term: Performance optimization, monitoring
|
||||
|
||||
### 4. Code Examples
|
||||
For each CRITICAL and HIGH severity issue, provide:
|
||||
- Current problematic code
|
||||
- Recommended refactored code
|
||||
- Migration steps
|
||||
|
||||
---
|
||||
|
||||
## Files to Prioritize
|
||||
|
||||
Start your audit with these critical files:
|
||||
|
||||
1. `/lib/providers/music_assistant_provider.dart` (4,469 LOC) - God class
|
||||
2. `/lib/services/settings_service.dart` - Credential storage
|
||||
3. `/lib/services/music_assistant_api.dart` (2,929 LOC) - Networking
|
||||
4. `/lib/widgets/expandable_player.dart` (2,485 LOC) - UI complexity
|
||||
5. `/lib/services/auth/` directory - Security
|
||||
6. `/lib/models/player.dart` - Memory leaks
|
||||
7. `/lib/services/cache_service.dart` - Unbounded caches
|
||||
8. `/lib/services/position_tracker.dart` - Audio sync issues
|
||||
|
||||
---
|
||||
|
||||
## Known Critical Issues (Pre-Identified)
|
||||
|
||||
These issues have been identified and MUST be verified and documented:
|
||||
|
||||
1. **CRITICAL: Plaintext credential storage** - SharedPreferences without encryption
|
||||
2. **CRITICAL: Zero test coverage** - No unit, widget, or integration tests
|
||||
3. **CRITICAL: 4,469 line god class** - MusicAssistantProvider violates SRP
|
||||
4. **CRITICAL: Silent exception handlers** - `catch (e) {}` patterns
|
||||
5. **HIGH: Static memory leak** - `_playerCreationTimes` unbounded growth
|
||||
6. **HIGH: Race conditions** - Player state polling vs events
|
||||
7. **HIGH: No accessibility** - Missing Semantics, screen reader support
|
||||
8. **HIGH: Certificate validation disabled** - MITM vulnerability for local IPs
|
||||
9. **MEDIUM: Double opacity bug** - `.withOpacity().withOpacity()` in player controls
|
||||
10. **MEDIUM: Position tracker drift** - No timeout on anchor updates
|
||||
|
||||
---
|
||||
|
||||
## Audit Execution Notes
|
||||
|
||||
- Use `Grep` tool to search for patterns across the codebase
|
||||
- Use `Read` tool to examine specific file sections
|
||||
- Document line numbers for all findings
|
||||
- Provide code snippets for context
|
||||
- Cross-reference related issues across files
|
||||
- Verify pre-identified issues exist and document their exact locations
|
||||
|
||||
**Total estimated audit time: 4-6 hours for thorough coverage**
|
||||
-419
@@ -1,419 +0,0 @@
|
||||
# Ensemble Codebase - Comprehensive Audit Report
|
||||
|
||||
**Audit Date:** 2026-01-01
|
||||
**Codebase:** Ensemble Flutter App (Music Assistant Client)
|
||||
**Version:** 2.7.3-beta+35
|
||||
**Total Lines of Code:** ~40,583 Dart
|
||||
**Files Analyzed:** 90 Dart files
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
### Overall Code Quality Score: 5.5/10
|
||||
|
||||
The Ensemble codebase demonstrates **strong architectural foundations** with a well-organized service layer, effective caching strategies, and sophisticated audio handling. However, the audit revealed **critical security vulnerabilities**, **zero test coverage**, and significant **technical debt** that must be addressed before production release.
|
||||
|
||||
### Critical Statistics
|
||||
|
||||
| Metric | Value | Status |
|
||||
|--------|-------|--------|
|
||||
| Test Coverage | 0% | CRITICAL |
|
||||
| God Class Size | 4,469 LOC | CRITICAL |
|
||||
| Silent Exception Handlers | 7 | CRITICAL |
|
||||
| Plaintext Credentials | 4 locations | CRITICAL |
|
||||
| Memory Leak Locations | 6 | HIGH |
|
||||
| Race Conditions | 4 | HIGH |
|
||||
| Accessibility (Semantics) | 0 widgets | CRITICAL |
|
||||
|
||||
---
|
||||
|
||||
## Issue Summary by Severity
|
||||
|
||||
| Severity | Count | Categories |
|
||||
|----------|-------|------------|
|
||||
| **CRITICAL** | 12 | Security, Testing, Architecture, Accessibility |
|
||||
| **HIGH** | 18 | Memory, Race Conditions, Networking, Audio |
|
||||
| **MEDIUM** | 22 | Error Handling, UI, Caching, Code Quality |
|
||||
| **LOW** | 14 | Naming, Documentation, Minor Improvements |
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL ISSUES (Immediate Action Required)
|
||||
|
||||
### 1. Plaintext Credential Storage
|
||||
**File:** `/lib/services/settings_service.dart`
|
||||
**Lines:** 131-143, 147-159, 169-190, 209-222
|
||||
|
||||
Passwords and auth tokens stored in SharedPreferences without encryption.
|
||||
|
||||
```dart
|
||||
// VULNERABLE CODE
|
||||
static Future<void> setPassword(String? password) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyPassword, password); // PLAINTEXT!
|
||||
}
|
||||
```
|
||||
|
||||
**Fix:** Use `flutter_secure_storage` for all sensitive data.
|
||||
|
||||
---
|
||||
|
||||
### 2. Zero Test Coverage
|
||||
**Location:** Entire codebase
|
||||
|
||||
No unit tests, widget tests, or integration tests exist. The `flutter_test` dependency is unused.
|
||||
|
||||
**Impact:** Cannot safely refactor, high regression risk, no confidence in correctness.
|
||||
|
||||
**Fix:** Establish test infrastructure immediately. Start with:
|
||||
- Unit tests for `MusicAssistantAPI`, `CacheService`, `ErrorHandler`
|
||||
- Widget tests for player controls
|
||||
- Integration tests for playback flows
|
||||
|
||||
---
|
||||
|
||||
### 3. God Class - MusicAssistantProvider
|
||||
**File:** `/lib/providers/music_assistant_provider.dart`
|
||||
**Size:** 4,469 lines, ~140 methods, 744 conditional branches
|
||||
|
||||
Single class handles: connection, players, library, cache, audio, sync, offline queue.
|
||||
|
||||
**Fix:** Split into:
|
||||
- `ConnectionManager` (~500 LOC)
|
||||
- `PlayerManager` (~800 LOC)
|
||||
- `LibraryCacheService` (~600 LOC)
|
||||
- `SendspinManager` (~400 LOC)
|
||||
- `MusicAssistantProvider` - Facade composing above (~800 LOC)
|
||||
|
||||
---
|
||||
|
||||
### 4. Zero Accessibility Support
|
||||
**Files:** `/lib/widgets/`, `/lib/screens/` (all)
|
||||
|
||||
- **0** `Semantics` widgets
|
||||
- **0** `semanticLabel` properties
|
||||
- **1** `Tooltip` (entire app)
|
||||
- Screen readers cannot navigate the app
|
||||
|
||||
**Fix:** Add Semantics to all interactive elements:
|
||||
```dart
|
||||
Semantics(
|
||||
button: true,
|
||||
label: isPlaying ? 'Pause playback' : 'Play',
|
||||
child: IconButton(...)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Silent Exception Handlers
|
||||
**Locations:** 7 instances across codebase
|
||||
|
||||
| File | Line | Code |
|
||||
|------|------|------|
|
||||
| player_provider.dart | 567, 575, 584 | `catch (e) {}` |
|
||||
| music_assistant_provider.dart | 2690 | `catch (e) {}` |
|
||||
| music_assistant_api.dart | 2542 | `catch (e) {}` |
|
||||
| recently_played_service.dart | 154, 206 | `catch (_) {}` |
|
||||
|
||||
**Fix:** Add logging or use `firstWhereOrNull` instead of try/catch around `firstWhere`.
|
||||
|
||||
---
|
||||
|
||||
### 6. Audio Session Listeners Not Disposed
|
||||
**File:** `/lib/services/audio/massiv_audio_handler.dart`
|
||||
**Lines:** 45-73
|
||||
|
||||
Stream subscriptions created but never cancelled - causes memory leaks and zombie listeners.
|
||||
|
||||
**Fix:** Store subscriptions and cancel in dispose():
|
||||
```dart
|
||||
StreamSubscription? _interruptionSubscription;
|
||||
_interruptionSubscription = session.interruptionEventStream.listen(...);
|
||||
|
||||
void dispose() {
|
||||
_interruptionSubscription?.cancel();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HIGH SEVERITY ISSUES
|
||||
|
||||
### 7. Connection State Subscription Not Stored
|
||||
**File:** `/lib/providers/music_assistant_provider.dart:575-614`
|
||||
|
||||
The `connectionState.listen()` return value is not stored - causes listener accumulation on reconnect.
|
||||
|
||||
---
|
||||
|
||||
### 8. Player State Race Condition
|
||||
**File:** `/lib/providers/music_assistant_provider.dart`
|
||||
|
||||
Timer polling (every 5s) and WebSocket events can race to update `_selectedPlayer` and `_currentTrack` without mutex protection.
|
||||
|
||||
---
|
||||
|
||||
### 9. Async Method Reentrancy
|
||||
**File:** `/lib/providers/music_assistant_provider.dart:2713-2841`
|
||||
|
||||
`selectPlayer()` is `void async` with no guard against concurrent calls. Multiple rapid player switches can interleave.
|
||||
|
||||
---
|
||||
|
||||
### 10. Unbounded Search Cache
|
||||
**File:** `/lib/services/cache_service.dart:31-32`
|
||||
|
||||
`_searchCache` map has no size limit or proactive TTL eviction. Grows indefinitely with user searches.
|
||||
|
||||
---
|
||||
|
||||
### 11. Static Memory Leak
|
||||
**File:** `/lib/models/player.dart:96, 139-144`
|
||||
|
||||
`_playerCreationTimes` static map cleanup only removes 50 of 100+ entries, allowing unbounded growth.
|
||||
|
||||
---
|
||||
|
||||
### 12. No Database Indexes
|
||||
**File:** `/lib/database/database.dart`
|
||||
|
||||
No secondary indexes defined. Query performance degrades as database grows.
|
||||
|
||||
---
|
||||
|
||||
### 13. No Incremental Sync
|
||||
**File:** `/lib/services/sync_service.dart:150-165`
|
||||
|
||||
Full library fetch (up to 4000 items) on every sync instead of delta updates.
|
||||
|
||||
---
|
||||
|
||||
### 14. Offline Queue Not Atomic
|
||||
**File:** `/lib/services/offline_action_queue.dart:80-108`
|
||||
|
||||
Queue persistence happens after all processing - crash during processing loses successful actions.
|
||||
|
||||
---
|
||||
|
||||
### 15. Auth Retry Without Deduplication
|
||||
**File:** `/lib/services/sendspin_service.dart:189-209`
|
||||
|
||||
Multiple auth messages can be sent during rapid reconnection attempts.
|
||||
|
||||
---
|
||||
|
||||
### 16. Pending Requests Not Completed on Disconnect
|
||||
**File:** `/lib/services/music_assistant_api.dart:2911`
|
||||
|
||||
`_pendingRequests.clear()` abandons Completers without completing them with errors.
|
||||
|
||||
---
|
||||
|
||||
### 17. HTTP Fallback for Local IPs
|
||||
**File:** `/lib/services/auth/auth_manager.dart:51-58`
|
||||
|
||||
HTTPS failures silently fall back to HTTP on local networks - MITM vulnerability.
|
||||
|
||||
---
|
||||
|
||||
### 18. Certificate Validation Bypassed
|
||||
**File:** `/lib/services/auth/auth_manager.dart:210-236`
|
||||
|
||||
HTTPS redirects explicitly refused for local IPs due to expected certificate failures.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM SEVERITY ISSUES
|
||||
|
||||
### 19. Double Opacity Bug
|
||||
**File:** `/lib/widgets/player/player_controls.dart:62-63, 99-100`
|
||||
|
||||
```dart
|
||||
// BUG: .withOpacity(0.5).withOpacity(expandedElementsOpacity) compounds opacity
|
||||
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
||||
.withOpacity(expandedElementsOpacity),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 20. Mixed Async Patterns
|
||||
**Locations:** 13 instances
|
||||
|
||||
`.then()` chains mixed with `async/await` throughout the codebase.
|
||||
|
||||
---
|
||||
|
||||
### 21. Hardcoded Colors
|
||||
**Locations:** 55 instances
|
||||
|
||||
`Color(0xFF604CEC)`, `Color(0xFF1a1a1a)` scattered across theme files and widgets.
|
||||
|
||||
---
|
||||
|
||||
### 22. View Modes as Strings
|
||||
**Locations:** 80+ instances
|
||||
|
||||
`'grid2'`, `'grid3'`, `'list'` should be enums.
|
||||
|
||||
---
|
||||
|
||||
### 23. Fire-and-Forget Pause Commands
|
||||
**File:** `/lib/providers/music_assistant_provider.dart:3875-3894`
|
||||
|
||||
Server pause command uses `unawaited()` - errors are silently lost.
|
||||
|
||||
---
|
||||
|
||||
### 24. Position Tracker Anchor Timeout Missing
|
||||
**File:** `/lib/services/position_tracker.dart:24-26`
|
||||
|
||||
No timeout on stale anchor - interpolation can drift indefinitely.
|
||||
|
||||
---
|
||||
|
||||
### 25. PCM Buffer No Overflow Protection
|
||||
**File:** `/lib/services/pcm_audio_player.dart:68, 236-247`
|
||||
|
||||
Audio buffer grows unbounded if data arrives faster than consumption.
|
||||
|
||||
---
|
||||
|
||||
### 26. No Client-Side Rate Limiting
|
||||
**Location:** App-wide
|
||||
|
||||
No throttling on API requests during rapid user interactions.
|
||||
|
||||
---
|
||||
|
||||
### 27. Portrait-Only Lock
|
||||
**File:** `/lib/main.dart:64-68`
|
||||
|
||||
No landscape orientation support.
|
||||
|
||||
---
|
||||
|
||||
### 28. Touch Targets Below 48dp
|
||||
**Files:** player_controls.dart, queue_panel.dart, player_card.dart
|
||||
|
||||
Multiple buttons at 28x28 and 44x44 instead of minimum 48x48.
|
||||
|
||||
---
|
||||
|
||||
### 29. WCAG Contrast Not Validated
|
||||
**File:** `/lib/theme/palette_helper.dart`
|
||||
|
||||
Uses 0.4 threshold instead of proper WCAG 4.5:1 contrast ratio calculation.
|
||||
|
||||
---
|
||||
|
||||
### 30. StreamControllers Not Closed
|
||||
**File:** `/lib/services/hardware_volume_service.dart:18-19, 74-85`
|
||||
|
||||
`_volumeUpController` and `_volumeDownController` never closed in dispose().
|
||||
|
||||
---
|
||||
|
||||
## LOW SEVERITY ISSUES
|
||||
|
||||
### 31-34. Naming Inconsistencies
|
||||
Mixed patterns: `_isLoading` vs `_isLoadingPlaylists`, various cache variable names.
|
||||
|
||||
### 35-38. Missing TODO Implementations
|
||||
6 actionable TODOs identified (queue removal, audiobook progress sync, playlist modification).
|
||||
|
||||
### 39-42. Dead/Unused Code
|
||||
- `@Deprecated` methods still implemented
|
||||
- `library_stats.dart` appears unused
|
||||
- `animation_debugger.dart` disabled by default
|
||||
|
||||
### 43-44. Minor Documentation Gaps
|
||||
Missing method documentation, no architecture diagram.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Remediation Roadmap
|
||||
|
||||
### Sprint 1 (Week 1-2): Security & Critical Fixes
|
||||
1. Migrate credentials to `flutter_secure_storage`
|
||||
2. Add logging to all silent catch blocks
|
||||
3. Store connection state subscription
|
||||
4. Fix audio session listener disposal
|
||||
5. Add basic test infrastructure (10% coverage target)
|
||||
|
||||
### Sprint 2 (Week 3-4): Memory & Race Conditions
|
||||
6. Add mutex to player state updates
|
||||
7. Add reentrancy guard to `selectPlayer()`
|
||||
8. Implement bounded caches with LRU eviction
|
||||
9. Fix static memory leak in Player model
|
||||
10. Store and cancel all stream subscriptions
|
||||
|
||||
### Sprint 3 (Week 5-6): Architecture Refactoring
|
||||
11. Split `MusicAssistantProvider` into 5 smaller classes
|
||||
12. Split `expandable_player.dart` into component widgets
|
||||
13. Replace GlobalKey anti-patterns with Provider
|
||||
14. Convert string constants to enums
|
||||
15. Extract hardcoded colors to constants file
|
||||
|
||||
### Sprint 4 (Week 7-8): Accessibility & UI
|
||||
16. Add Semantics to all interactive elements
|
||||
17. Increase touch targets to 48dp minimum
|
||||
18. Implement WCAG contrast validation
|
||||
19. Fix double opacity bug
|
||||
20. Add landscape orientation support
|
||||
|
||||
### Sprint 5 (Week 9-10): Performance & Polish
|
||||
21. Add database indexes
|
||||
22. Implement incremental sync
|
||||
23. Add client-side rate limiting
|
||||
24. Fix atomic offline queue processing
|
||||
25. Achieve 30% test coverage target
|
||||
|
||||
---
|
||||
|
||||
## Files Requiring Most Attention
|
||||
|
||||
| File | LOC | Issues | Priority |
|
||||
|------|-----|--------|----------|
|
||||
| `music_assistant_provider.dart` | 4,469 | 12 | CRITICAL |
|
||||
| `settings_service.dart` | 811 | 4 | CRITICAL |
|
||||
| `expandable_player.dart` | 2,485 | 6 | HIGH |
|
||||
| `music_assistant_api.dart` | 2,929 | 5 | HIGH |
|
||||
| `cache_service.dart` | 370 | 4 | HIGH |
|
||||
| `massiv_audio_handler.dart` | 296 | 2 | CRITICAL |
|
||||
| `player.dart` | 422 | 2 | HIGH |
|
||||
| `auth_manager.dart` | 300 | 3 | HIGH |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Technical Debt
|
||||
|
||||
| Category | Effort (Dev-Days) |
|
||||
|----------|-------------------|
|
||||
| Security Fixes | 3-5 |
|
||||
| Test Infrastructure | 10-15 |
|
||||
| Architecture Refactoring | 15-20 |
|
||||
| Memory Leak Fixes | 3-5 |
|
||||
| Accessibility | 5-8 |
|
||||
| UI/UX Polish | 3-5 |
|
||||
| Performance | 5-8 |
|
||||
| **Total** | **44-66 dev-days** |
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Ensemble app has a solid foundation with well-designed patterns for caching, audio handling, and multi-device synchronization. However, **critical security vulnerabilities** (plaintext credentials), **zero test coverage**, and a **4,469-line god class** represent significant risks that must be addressed before any production deployment.
|
||||
|
||||
The most urgent priorities are:
|
||||
1. **Secure credential storage** - immediate security risk
|
||||
2. **Add test infrastructure** - enables safe refactoring
|
||||
3. **Split the god class** - enables maintainability
|
||||
4. **Add accessibility** - legal/compliance requirement
|
||||
|
||||
With focused effort over 8-10 weeks, the codebase can be brought to production-quality standards.
|
||||
|
||||
---
|
||||
|
||||
*Audit conducted using 6 parallel specialized sub-agents analyzing security, memory management, race conditions, architecture, UI/UX, and audio/networking.*
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
# Ensemble Codebase Audit Prompt
|
||||
|
||||
Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app.
|
||||
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files.
|
||||
|
||||
**CRITICAL RULES:**
|
||||
1. DO NOT remove or break any existing functionality
|
||||
2. All changes must be on the current branch: `audit/code-quality-review`
|
||||
3. Run `flutter analyze` after each set of changes to verify no regressions
|
||||
4. Commit changes in logical, reviewable chunks
|
||||
|
||||
**AUDIT PHASES:**
|
||||
|
||||
### Phase 1: Code Quality Analysis (Read-Only)
|
||||
First, analyze the codebase without making changes. Create a report covering:
|
||||
|
||||
1. **Large File Analysis** - Review these files for refactoring opportunities:
|
||||
- `lib/screens/new_library_screen.dart` (122KB)
|
||||
- `lib/screens/search_screen.dart` (129KB)
|
||||
- `lib/widgets/expandable_player.dart` (107KB)
|
||||
- `lib/screens/album_details_screen.dart` (48KB)
|
||||
- `lib/screens/artist_details_screen.dart` (45KB)
|
||||
|
||||
2. **Pattern Inconsistencies** - Look for:
|
||||
- Inconsistent naming conventions (camelCase vs snake_case)
|
||||
- Mixed widget composition patterns
|
||||
- Inconsistent error handling approaches
|
||||
- Duplicate code across screens/widgets
|
||||
|
||||
3. **Performance Red Flags** - Identify:
|
||||
- Unnecessary rebuilds (missing const constructors)
|
||||
- Heavy computations in build methods
|
||||
- Missing list item caching (ListView.builder vs ListView)
|
||||
- Inefficient state management patterns
|
||||
|
||||
4. **Scroll Performance Issues** - Specifically audit:
|
||||
- ScrollController usage and disposal
|
||||
- Sliver implementations
|
||||
- AnimatedBuilder/AnimatedWidget usage
|
||||
- Image loading in scrollable lists
|
||||
- Any jank-inducing patterns
|
||||
|
||||
### Phase 2: Quick Wins (Low Risk, High Impact)
|
||||
Apply these fixes first:
|
||||
|
||||
1. Add missing `const` constructors throughout
|
||||
2. Fix any `flutter analyze` warnings
|
||||
3. Remove dead code and unused imports
|
||||
4. Standardize formatting with `dart format`
|
||||
|
||||
### Phase 3: Scroll Animation Optimization
|
||||
Focus on smoother scrolling:
|
||||
|
||||
1. **Image Optimization**
|
||||
- Ensure CachedNetworkImage is used consistently
|
||||
- Add appropriate cacheWidth/cacheHeight
|
||||
- Implement proper placeholder/error widgets
|
||||
|
||||
2. **List Performance**
|
||||
- Convert any ListView to ListView.builder where appropriate
|
||||
- Add itemExtent where possible for fixed-height items
|
||||
- Implement AutomaticKeepAliveClientMixin where needed
|
||||
- Consider RepaintBoundary for complex list items
|
||||
|
||||
3. **Animation Smoothing**
|
||||
- Use `Curves.easeOutCubic` or similar for scroll-linked animations
|
||||
- Ensure animations run at 60fps (avoid heavy computations during animation)
|
||||
- Review CustomScrollView/Sliver implementations
|
||||
|
||||
### Phase 4: Code Consistency Improvements
|
||||
|
||||
1. **Widget Extraction**
|
||||
- Extract repeated UI patterns into reusable widgets
|
||||
- Ensure extracted widgets follow existing naming patterns
|
||||
|
||||
2. **State Management**
|
||||
- Verify Provider usage is consistent
|
||||
- Check for proper disposal of controllers/streams
|
||||
|
||||
3. **Error Handling**
|
||||
- Standardize try/catch patterns
|
||||
- Ensure consistent loading/error state UI
|
||||
|
||||
### Phase 5: Verification
|
||||
|
||||
After all changes:
|
||||
1. Run `flutter analyze` - must pass with 0 issues
|
||||
2. Run `flutter test` if tests exist
|
||||
3. Create a summary of all changes made
|
||||
4. List any recommendations that were NOT implemented (too risky)
|
||||
|
||||
---
|
||||
|
||||
## Execution Instructions
|
||||
|
||||
```bash
|
||||
# Start the audit
|
||||
cd /home/home-server/Ensemble
|
||||
git checkout audit/code-quality-review
|
||||
|
||||
# After each phase, commit your changes
|
||||
git add -A && git commit -m "Audit Phase X: [description]"
|
||||
|
||||
# Verify no regressions
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Deliverables
|
||||
|
||||
1. `AUDIT_REPORT.md` - Findings from Phase 1
|
||||
2. Multiple commits with clear messages for each phase
|
||||
3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations
|
||||
-257
@@ -1,257 +0,0 @@
|
||||
# Ensemble Codebase Audit Report
|
||||
|
||||
**Date**: 2026-01-14
|
||||
**Scope**: Full codebase audit (104 Dart files, 18 screens)
|
||||
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
|
||||
|
||||
| Severity | Count | Key Themes |
|
||||
|----------|-------|------------|
|
||||
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
|
||||
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
|
||||
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
|
||||
| **Low** | 15+ | Minor polish items |
|
||||
|
||||
**Top 3 Priorities:**
|
||||
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
|
||||
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
|
||||
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### 1. God Object: MusicAssistantProvider (5,443 lines)
|
||||
**File**: `lib/providers/music_assistant_provider.dart`
|
||||
- 54 `notifyListeners()` calls
|
||||
- 30+ state variables
|
||||
- 100+ public methods
|
||||
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
|
||||
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
|
||||
|
||||
### 2. God Object: NewLibraryScreen (4,209 lines)
|
||||
**File**: `lib/screens/new_library_screen.dart`
|
||||
- 10 scroll controllers
|
||||
- 15+ view mode states
|
||||
- 15+ sort order states
|
||||
- Handles: Music, Books, Podcasts, Radio tabs
|
||||
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
|
||||
|
||||
### 3. Sequential Blocking Startup (500-1500ms wasted)
|
||||
**File**: `lib/main.dart:33-66`
|
||||
```dart
|
||||
await DatabaseService.instance.initialize();
|
||||
await ProfileService.instance.migrateFromOwnerName();
|
||||
await SettingsService.migrateToSecureStorage();
|
||||
await SyncService.instance.loadFromCache();
|
||||
audioHandler = await AudioService.init(...);
|
||||
```
|
||||
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
|
||||
|
||||
### 4. Design Tokens Exist But Unused
|
||||
**File**: `lib/theme/design_tokens.dart`
|
||||
- `Spacing.*` used in only ~6 of 34+ widget files
|
||||
- `Radii.*` used in only ~5 files
|
||||
- `IconSizes.*` used in only ~4 files
|
||||
- **Fix**: Systematic replacement of hardcoded values
|
||||
|
||||
### 5. Touch Targets Too Small (32x32)
|
||||
**File**: `lib/screens/new_library_screen.dart:1805-1867`
|
||||
- Sort, favorites, view mode buttons are 32x32
|
||||
- Material Design minimum: 48x48
|
||||
- **Fix**: Increase to 48x48 or add padding for hit area
|
||||
|
||||
### 6. No Batch Database Inserts
|
||||
**File**: `lib/services/sync_service.dart:212-273`
|
||||
- 1000 albums = 1000 separate DB transactions
|
||||
- **Fix**: Wrap in single transaction or use Drift batch insert
|
||||
|
||||
---
|
||||
|
||||
## High Severity Issues
|
||||
|
||||
### Performance
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
|
||||
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
|
||||
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
|
||||
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
|
||||
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
|
||||
|
||||
### Architecture
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
|
||||
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
|
||||
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
|
||||
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
|
||||
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
|
||||
|
||||
### UI/UX
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
|
||||
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
|
||||
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
|
||||
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
|
||||
|
||||
### Error Handling
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| No Flutter error boundaries | main.dart | Red error screen on build failure |
|
||||
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
|
||||
|
||||
---
|
||||
|
||||
## Medium Severity Issues
|
||||
|
||||
### Performance
|
||||
- PaletteGenerator on main isolate (10-30ms blocking)
|
||||
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
|
||||
- AppStartup polling loop with fixed 250ms delay
|
||||
- SystemThemeHelper FutureBuilder recreated on every build
|
||||
|
||||
### UI Consistency
|
||||
- Border radius varies: 8, 12, 20, 22, 25 for same element types
|
||||
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
|
||||
- Hardcoded colors for status (Colors.red, Colors.green)
|
||||
- Inconsistent icon sizes across screens
|
||||
|
||||
### State Management
|
||||
- Over-notification (multiple notifyListeners in single methods)
|
||||
- Wide Consumer scope (watch instead of select)
|
||||
- Missing dispose() in some providers
|
||||
- Inconsistent async state handling (loading/error)
|
||||
|
||||
### Navigation
|
||||
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
|
||||
- `popToHome()` doesn't clear adaptive colors
|
||||
- Infinite navigation loop possible (Album → Artist → Album)
|
||||
- Orphaned Hero animations in podcast_card, radio_station_card
|
||||
|
||||
### Data/Caching
|
||||
- Unbounded `_podcastCoverCache`
|
||||
- SyncService listener not removed on error
|
||||
- Redundant API calls between providers
|
||||
- Fire-and-forget persistence race condition
|
||||
|
||||
### Error Handling
|
||||
- No zone error handler for async exceptions
|
||||
- `retryCritical()` retries all errors including non-recoverable
|
||||
- No user notification of offline queue status
|
||||
- DisconnectedState not used consistently
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins (High Impact, Low Effort)
|
||||
|
||||
| # | Task | File(s) | Effort |
|
||||
|---|------|---------|--------|
|
||||
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
|
||||
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
|
||||
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
|
||||
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
|
||||
| 5 | Add Flutter error boundary | main.dart | 30 min |
|
||||
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
|
||||
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Roadmap
|
||||
|
||||
### Phase 1: Quick Wins (1-2 days)
|
||||
- Fix touch targets
|
||||
- Fix bottom spacing
|
||||
- Add navigation debounce
|
||||
- Add error boundaries
|
||||
- Cache SharedPreferences singleton
|
||||
|
||||
### Phase 2: Performance (3-5 days)
|
||||
- Parallelize startup operations
|
||||
- Replace `context.watch()` with `context.select()`
|
||||
- Batch database inserts
|
||||
- Add SecureStorage migration skip flag
|
||||
- Fix over-notification in provider
|
||||
|
||||
### Phase 3: Design System (1 week)
|
||||
- Systematic design token adoption
|
||||
- Standardize button sizes (44 vs 50)
|
||||
- Standardize border radius values
|
||||
- Create StatusColors semantic tokens
|
||||
- Replace all magic numbers
|
||||
|
||||
### Phase 4: Architecture (2-3 weeks)
|
||||
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
|
||||
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
|
||||
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
|
||||
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
|
||||
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
|
||||
|
||||
---
|
||||
|
||||
## Positive Findings
|
||||
|
||||
The codebase already demonstrates many good practices:
|
||||
|
||||
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
|
||||
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
|
||||
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
|
||||
- **Theme system**: Material You support, adaptive colors from album art
|
||||
- **Offline support**: Cache-first loading, graceful degradation
|
||||
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
|
||||
|
||||
---
|
||||
|
||||
## Files by Priority
|
||||
|
||||
| Priority | File | Lines | Key Issues |
|
||||
|----------|------|-------|------------|
|
||||
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
|
||||
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
|
||||
| 3 | main.dart | 549 | Startup blocking, error boundaries |
|
||||
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
|
||||
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
|
||||
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
|
||||
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
|
||||
| 8 | All detail screens | Various | Hardcoded bottom spacing |
|
||||
| 9 | All card widgets | Various | Navigation debounce, design tokens |
|
||||
|
||||
---
|
||||
|
||||
## Audit Methodology
|
||||
|
||||
This audit was conducted using 9 parallel sub-agents analyzing:
|
||||
|
||||
**UI Continuity (3 agents)**
|
||||
1. Navigation & Flow - back button, Hero animations, tab preservation
|
||||
2. Visual Consistency - design tokens, spacing, typography
|
||||
3. Component Placement - touch targets, bottom spacing, modals
|
||||
|
||||
**Performance (3 agents)**
|
||||
4. Rendering Performance - ListView.builder, const, rebuilds
|
||||
5. Data & Caching - streams, invalidation, memory bounds
|
||||
6. Startup & Load Time - blocking operations, initialization order
|
||||
|
||||
**Logic/Best Practices (3 agents)**
|
||||
7. State Management - Provider patterns, notification frequency
|
||||
8. Error Handling - try-catch, boundaries, resilience
|
||||
9. Code Quality - file size, duplication, SOLID principles
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
|
||||
|
||||
1. **Splitting the two 4,000+ line files** into focused components
|
||||
2. **Adopting design tokens systematically** for visual consistency
|
||||
3. **Optimizing startup sequence** for faster app launch
|
||||
4. **Using granular state management** (select vs watch) for better performance
|
||||
|
||||
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Audit Summary
|
||||
|
||||
**Branch:** `audit/code-quality-review`
|
||||
**Date:** January 4, 2026
|
||||
|
||||
## Changes Applied
|
||||
|
||||
### Phase 1: Analysis Complete
|
||||
- Created `AUDIT_REPORT.md` with comprehensive findings
|
||||
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
|
||||
|
||||
### Phase 2: Deprecated API Fixes (18 files)
|
||||
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
|
||||
|
||||
**Files modified:**
|
||||
- lib/screens/library_artists_screen.dart
|
||||
- lib/screens/library_albums_screen.dart
|
||||
- lib/screens/library_playlists_screen.dart
|
||||
- lib/screens/library_tracks_screen.dart
|
||||
- lib/screens/artist_details_screen.dart
|
||||
- lib/screens/new_home_screen.dart
|
||||
- lib/screens/login_screen.dart
|
||||
- lib/screens/settings_screen.dart
|
||||
- lib/screens/album_details_screen.dart
|
||||
- lib/screens/search_screen.dart
|
||||
- lib/screens/new_library_screen.dart
|
||||
|
||||
### Phase 3: Scroll Performance Optimization
|
||||
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
|
||||
|
||||
**lib/screens/new_library_screen.dart:**
|
||||
- Line 1400-1401: Author list tile image (128x128 cache)
|
||||
- Line 1487-1488: Author grid card image (256x256 cache)
|
||||
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
|
||||
- Line 1749-1750: Audiobook grid cover (512x512 cache)
|
||||
- Line 1970-1971: Series list tile cover (256x256 cache)
|
||||
- Line 2986-2987: Album list tile cover (256x256 cache)
|
||||
|
||||
## Recommendations NOT Implemented (Low Priority)
|
||||
|
||||
These were identified but NOT changed to preserve functionality:
|
||||
|
||||
1. **View mode cycling refactor** - 8 similar functions could be consolidated
|
||||
2. **Color constant extraction** - Duplicated colors in 3 files
|
||||
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
|
||||
4. **BorderRadius.circular const** - 40+ instances
|
||||
|
||||
## Verification
|
||||
|
||||
Changes should be verified by:
|
||||
1. GitHub Actions build (triggered automatically)
|
||||
2. Manual UI testing of scroll smoothness
|
||||
3. Verify no visual regressions in dark/light themes
|
||||
|
||||
## Build Trigger
|
||||
|
||||
GitHub Actions workflow triggered on branch `audit/code-quality-review`
|
||||
@@ -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,6 +1,12 @@
|
||||
<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/latest)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/sponsors/CollotsSpot)
|
||||
[](https://ko-fi.com/collotsspot)
|
||||
|
||||
---
|
||||
|
||||
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
|
||||
@@ -17,6 +23,12 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
|
||||
---
|
||||
|
||||
## Also See
|
||||
|
||||
**[Ensemble TV](https://github.com/CollotsSpot/ensemble-tv)** - A simple Android TV client for Music Assistant that displays what's currently playing on your TV with remote control support.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Local Playback
|
||||
@@ -74,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
|
||||
@@ -129,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).
|
||||
@@ -140,6 +170,13 @@ For access outside your home network, you'll need to expose Music Assistant thro
|
||||
- Android device (Android 5.0+)
|
||||
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
||||
|
||||
## Sponsors
|
||||
|
||||
A huge thank you to my sponsors! 💖
|
||||
|
||||
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
|
||||
<a href="https://github.com/githubchb"><img src="https://github.com/githubchb.png" width="60px" alt="githubchb" /></a>
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -30,7 +30,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
android {
|
||||
namespace "com.collotsspot.ensemble"
|
||||
compileSdk 35
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
@@ -77,6 +77,16 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86_64': 3]
|
||||
android.applicationVariants.configureEach { variant ->
|
||||
variant.outputs.each { output ->
|
||||
def abiVersionCode = abiCodes[output.getFilter('ABI')]
|
||||
if (abiVersionCode != null) {
|
||||
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
@@ -48,6 +54,10 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.collotsspot.ensemble
|
||||
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
@@ -14,10 +20,22 @@ class MainActivity: AudioServiceActivity() {
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var isListening = false
|
||||
|
||||
// Volume observer for lockscreen volume changes
|
||||
// Watches system STREAM_MUSIC volume and mirrors changes to the MA player
|
||||
// when a remote/group player is active
|
||||
private var audioManager: AudioManager? = null
|
||||
private var volumeObserver: VolumeContentObserver? = null
|
||||
private var isObservingVolume = false
|
||||
private var lastKnownVolume: Int = -1
|
||||
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
|
||||
private var ignoringVolumeChange = false
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
|
||||
|
||||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
Log.d(TAG, "Received method call: ${call.method}")
|
||||
@@ -32,6 +50,24 @@ class MainActivity: AudioServiceActivity() {
|
||||
Log.d(TAG, "Volume listening DISABLED")
|
||||
result.success(null)
|
||||
}
|
||||
"startVolumeObserver" -> {
|
||||
// Start observing system volume for lockscreen volume changes.
|
||||
// Optional: initialVolume (0-100) sets the system volume to match
|
||||
// the MA player's current volume for a consistent HUD display.
|
||||
val initialVolume = call.argument<Int>("initialVolume")
|
||||
startVolumeObserver(initialVolume)
|
||||
result.success(null)
|
||||
}
|
||||
"stopVolumeObserver" -> {
|
||||
stopVolumeObserver()
|
||||
result.success(null)
|
||||
}
|
||||
"syncSystemVolume" -> {
|
||||
// Sync system volume to match MA player volume (0-100)
|
||||
val volume = call.argument<Int>("volume") ?: 0
|
||||
syncSystemVolume(volume)
|
||||
result.success(null)
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Unknown method: ${call.method}")
|
||||
result.notImplemented()
|
||||
@@ -40,6 +76,82 @@ class MainActivity: AudioServiceActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start observing system STREAM_MUSIC volume changes.
|
||||
/// When a change is detected, sends an "absoluteVolumeChange" event to Flutter
|
||||
/// with the new volume mapped to 0-100 scale.
|
||||
private fun startVolumeObserver(initialVolume: Int?) {
|
||||
if (isObservingVolume) return
|
||||
|
||||
val am = audioManager ?: return
|
||||
|
||||
// Optionally set system volume to match the MA player's current volume
|
||||
if (initialVolume != null) {
|
||||
syncSystemVolume(initialVolume)
|
||||
}
|
||||
|
||||
lastKnownVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
|
||||
volumeObserver = VolumeContentObserver(Handler(Looper.getMainLooper()))
|
||||
contentResolver.registerContentObserver(
|
||||
Settings.System.CONTENT_URI,
|
||||
true,
|
||||
volumeObserver!!
|
||||
)
|
||||
isObservingVolume = true
|
||||
Log.d(TAG, "Volume observer STARTED, initial system volume: $lastKnownVolume")
|
||||
}
|
||||
|
||||
/// Stop observing system volume changes.
|
||||
private fun stopVolumeObserver() {
|
||||
if (!isObservingVolume) return
|
||||
|
||||
volumeObserver?.let {
|
||||
contentResolver.unregisterContentObserver(it)
|
||||
}
|
||||
volumeObserver = null
|
||||
isObservingVolume = false
|
||||
Log.d(TAG, "Volume observer STOPPED")
|
||||
}
|
||||
|
||||
/// Set the system STREAM_MUSIC volume to match an MA player volume (0-100).
|
||||
/// Maps MA's 0-100 range to the device's 0-maxVolume range.
|
||||
private fun syncSystemVolume(maVolume: Int) {
|
||||
val am = audioManager ?: return
|
||||
val maxSystemVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val systemVolume = (maVolume * maxSystemVolume / 100).coerceIn(0, maxSystemVolume)
|
||||
|
||||
ignoringVolumeChange = true
|
||||
am.setStreamVolume(AudioManager.STREAM_MUSIC, systemVolume, 0)
|
||||
lastKnownVolume = systemVolume
|
||||
ignoringVolumeChange = false
|
||||
Log.d(TAG, "Synced system volume: MA $maVolume% -> system $systemVolume/$maxSystemVolume")
|
||||
}
|
||||
|
||||
/// ContentObserver that watches for system volume changes.
|
||||
/// When volume changes (e.g., from lockscreen hardware buttons),
|
||||
/// maps the change to a 0-100 value and sends to Flutter.
|
||||
inner class VolumeContentObserver(handler: Handler) : ContentObserver(handler) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
super.onChange(selfChange)
|
||||
|
||||
if (ignoringVolumeChange) return
|
||||
|
||||
val am = audioManager ?: return
|
||||
val currentVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
|
||||
if (currentVolume != lastKnownVolume) {
|
||||
val maxVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val maVolume = if (maxVolume > 0) (currentVolume * 100 / maxVolume) else 0
|
||||
|
||||
Log.d(TAG, "Volume observer: system $lastKnownVolume -> $currentVolume (MA: $maVolume%)")
|
||||
lastKnownVolume = currentVolume
|
||||
|
||||
// Send the absolute volume (0-100) to Flutter
|
||||
methodChannel?.invokeMethod("absoluteVolumeChange", maVolume)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
|
||||
// and may consume events before they reach onKeyDown
|
||||
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
|
||||
@@ -84,4 +196,34 @@ class MainActivity: AudioServiceActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pause volume interception when app goes to background so hardware buttons
|
||||
// only control the system volume (e.g., YouTube, phone ringer) as expected.
|
||||
private var wasListeningBeforePause = false
|
||||
private var wasObservingBeforePause = false
|
||||
|
||||
override fun onPause() {
|
||||
wasListeningBeforePause = isListening
|
||||
wasObservingBeforePause = isObservingVolume
|
||||
isListening = false
|
||||
stopVolumeObserver()
|
||||
Log.d(TAG, "onPause: suspended volume interception (wasListening=$wasListeningBeforePause, wasObserving=$wasObservingBeforePause)")
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (wasListeningBeforePause) {
|
||||
isListening = true
|
||||
}
|
||||
if (wasObservingBeforePause) {
|
||||
startVolumeObserver(null)
|
||||
}
|
||||
Log.d(TAG, "onResume: restored volume interception (listening=$isListening, observing=$isObservingVolume)")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopVolumeObserver()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,16.5c-2.49,0 -4.5,-2.01 -4.5,-4.5S9.51,7.5 12,7.5s4.5,2.01 4.5,4.5 -2.01,4.5 -4.5,4.5zM12,11c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55C7.79,13 6,14.79 6,17s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M15,6H3v2h12V6zM15,10H3v2h12V10zM3,16h8v-2H3V16zM17,6v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3V8h3V6H17z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M3.24,6.15C2.51,6.43 2,7.17 2,8v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2H8.3l8.26,-3.34 -0.37,-0.92L3.24,6.15zM7,20c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM20,12H12V9h8v3z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -2,14 +2,11 @@ buildscript {
|
||||
ext.kotlin_version = '2.1.0'
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.7.0'
|
||||
classpath 'com.android.tools.build:gradle:8.9.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
@@ -17,9 +14,6 @@ buildscript {
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -22,7 +22,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.7.0" apply false
|
||||
id "com.android.application" version "8.9.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Discovery Rows Submenu - Implementation Plan
|
||||
|
||||
## Option: Hybrid with Discovery Badge
|
||||
|
||||
### Overview
|
||||
Transform the "Discovery Mixes" toggle into a section that:
|
||||
1. Shows individual discovery row toggles
|
||||
2. Adds those rows to the main home row list with visual distinction
|
||||
3. Allows full reordering alongside other rows
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Settings Screen - Discovery Section
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ⚬ Discovery Mixes ● │
|
||||
│ Show discovery-based rows on home screen │
|
||||
│ ──────────────────────────────────────── │
|
||||
│ Discovery rows from Music Assistant: │
|
||||
│ │
|
||||
│ ☑ New Releases ● │
|
||||
│ ☑ Recently Played ● │
|
||||
│ ☐ Recommended Albums ○ │
|
||||
│ ☐ For You ○ │
|
||||
│ │
|
||||
│ [Refresh available rows] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Main Home Row List - With Badge
|
||||
|
||||
Discovery rows appear interleaved with a badge:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ⋮ ☑ Recent Albums ● │
|
||||
│ ⋮ ☑ Discovery: New Releases ● [MUSIC] │ ← Badge
|
||||
│ ⋮ ☑ Discover Artists ● │
|
||||
│ ⋮ ☐ Discovery: For You ○ [MUSIC] │
|
||||
│ ⋮ ☑ Favorite Albums ● │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Badge style: Small chip/icon indicating this is a dynamic discovery row.
|
||||
|
||||
---
|
||||
|
||||
## Technical Changes
|
||||
|
||||
### 1. Settings Screen (`lib/screens/settings_screen.dart`)
|
||||
|
||||
**Add state for discovery folders:**
|
||||
```dart
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
Map<String, bool> _discoveryRowEnabled = {}; // itemId -> enabled
|
||||
bool _isDiscoveryFoldersExpanded = false;
|
||||
```
|
||||
|
||||
**New methods:**
|
||||
```dart
|
||||
Future<void> _loadDiscoveryFolders() async { }
|
||||
Future<void> _refreshDiscoveryFolders() async { }
|
||||
void _toggleDiscoveryRow(String itemId, bool enabled) { }
|
||||
void _toggleDiscoveryMixes(bool enabled) { }
|
||||
Widget _buildDiscoverySection() { }
|
||||
```
|
||||
|
||||
**Modify existing:**
|
||||
- `_loadSettings()` - load discovery row preferences
|
||||
- Home row list builder - filter out individual discovery rows
|
||||
- Add discovery section before home row list
|
||||
|
||||
### 2. Settings Service (`lib/services/settings_service.dart`)
|
||||
|
||||
**New methods:**
|
||||
```dart
|
||||
static Future<bool> getShowDiscoveryFolders() async { }
|
||||
static Future<void> setShowDiscoveryFolders(bool value) async { }
|
||||
|
||||
// Per-row discovery preferences
|
||||
static Future<Map<String, bool>> getDiscoveryRowPreferences() async { }
|
||||
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async { }
|
||||
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async { }
|
||||
```
|
||||
|
||||
**Storage key pattern:**
|
||||
- `discovery_folders_enabled` (bool)
|
||||
- `discovery_row_{itemId}` (bool per row)
|
||||
|
||||
### 3. Provider (`lib/providers/music_assistant_provider.dart`)
|
||||
|
||||
**Already has:** `getDiscoveryFoldersWithCache()`
|
||||
|
||||
**Add method:**
|
||||
```dart
|
||||
Future<void> refreshDiscoveryFolders() async {
|
||||
// Clear cache and refetch from API
|
||||
_cacheService.clearDiscoveryFoldersCache();
|
||||
await getDiscoveryFoldersWithCache();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Home Screen (`lib/screens/new_home_screen.dart`)
|
||||
|
||||
**Changes to row rendering:**
|
||||
|
||||
Instead of a single `_discoveryFolders` list, discovery rows are integrated into `_homeRowOrder`:
|
||||
|
||||
```dart
|
||||
// Before: static list of row IDs
|
||||
List<String> _homeRowOrder = ['recent-albums', 'discover-artists', ...];
|
||||
|
||||
// After: can include discovery row IDs
|
||||
List<String> _homeRowOrder = [
|
||||
'recent-albums',
|
||||
'discovery:abc123', // Discovery row ID
|
||||
'discover-artists',
|
||||
'discovery:def456',
|
||||
...
|
||||
];
|
||||
```
|
||||
|
||||
**New method:**
|
||||
```dart
|
||||
bool _isDiscoveryRowId(String rowId) {
|
||||
return rowId.startsWith('discovery:');
|
||||
}
|
||||
|
||||
String _getDiscoveryRowItemId(String rowId) {
|
||||
return rowId.substring('discovery:'.length);
|
||||
}
|
||||
```
|
||||
|
||||
**Modify row rendering:**
|
||||
- Check if rowId is a discovery row
|
||||
- If yes, render DiscoveryRow widget with that folder
|
||||
- Cache discovery folders for quick lookup
|
||||
|
||||
### 5. Localization (`lib/l10n/app_en.arb`)
|
||||
|
||||
**Add strings:**
|
||||
```json
|
||||
{
|
||||
"discoveryMixesDescription": "Show personalized recommendations from Music Assistant",
|
||||
"discoveryRows": "Discovery rows from Music Assistant",
|
||||
"refreshDiscoveryRows": "Refresh available rows",
|
||||
"noDiscoveryRows": "No discovery rows available",
|
||||
"discoveryRowBadge": "Music Assistant"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Models (`lib/models/recommendation_folder.dart`)
|
||||
|
||||
**Add property:**
|
||||
```dart
|
||||
class RecommendationFolder {
|
||||
...
|
||||
/// Unique ID for use in row ordering and settings
|
||||
String get rowId => 'discovery:$itemId';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Settings Screen │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. Load discovery folders from Provider │
|
||||
│ 2. Load per-row enabled state from Settings │
|
||||
│ 3. Render section with toggles │
|
||||
│ 4. User toggles row → save to Settings │
|
||||
│ 5. User toggles main → save to Settings + update home row list │
|
||||
│ 6. User refreshes → Provider.fetch → update UI │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Home Screen │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. Load home row order from Settings │
|
||||
│ 2. Identify discovery row IDs (prefix "discovery:") │
|
||||
│ 3. For discovery rows: look up folder from Provider cache │
|
||||
│ 4. Render appropriate widget (DiscoveryRow or other) │
|
||||
│ 5. Handle reordering including discovery rows │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Settings Screen (No breaking changes)
|
||||
- Add discovery section to settings
|
||||
- Load and display discovery folders
|
||||
- Individual row toggles (but don't affect home screen yet)
|
||||
- Refresh button
|
||||
|
||||
### Phase 2: Home Screen Integration
|
||||
- Modify home row order to support discovery row IDs
|
||||
- Render discovery rows in main list
|
||||
- Add badge visual distinction
|
||||
|
||||
### Phase 3: Polish
|
||||
- Translations
|
||||
- Edge cases (folder disappears from MA)
|
||||
- Performance (lazy loading)
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases & Considerations
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Discovery folder no longer returned by MA | Keep user preference, hide row if not found |
|
||||
| User reorders then MA returns new folders | New folders appended to end of discovery section |
|
||||
| All discovery rows disabled | Discovery section shows but appears empty/minimized |
|
||||
| Many discovery folders (10+) | Show scrollable sublist, keep main setting compact |
|
||||
| Row ID collision | Use `discovery:{itemId}` prefix ensures uniqueness |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Settings: Add `_discoveryFolders` state
|
||||
- [ ] Settings: Add `_discoveryRowEnabled` map
|
||||
- [ ] Settings: Implement `_loadDiscoveryFolders()`
|
||||
- [ ] Settings: Implement `_buildDiscoverySection()`
|
||||
- [ ] Settings: Add refresh button handler
|
||||
- [ ] SettingsService: Add discovery row preference methods
|
||||
- [ ] Provider: Add `refreshDiscoveryFolders()`
|
||||
- [ ] HomeScreen: Support discovery row IDs in order list
|
||||
- [ ] HomeScreen: Render discovery rows with badge
|
||||
- [ ] Localization: Add new strings
|
||||
- [ ] Test: Toggle discovery rows on/off
|
||||
- [ ] Test: Reorder discovery rows
|
||||
- [ ] Test: Refresh discovery folders
|
||||
- [ ] Test: Migrate existing preference to new system
|
||||
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
@@ -2996,7 +3374,8 @@ final class $$ProfilesTableReferences
|
||||
|
||||
$$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
|
||||
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
|
||||
.filter((f) => f.profileUsername.username($_item.username));
|
||||
.filter((f) => f.profileUsername.username
|
||||
.sqlEquals($_itemColumn<String>('username')!));
|
||||
|
||||
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
|
||||
return ProcessedTableManager(
|
||||
@@ -3189,7 +3568,8 @@ class $$ProfilesTableTableManager extends RootTableManager<
|
||||
getPrefetchedDataCallback: (items) async {
|
||||
return [
|
||||
if (recentlyPlayedRefs)
|
||||
await $_getPrefetchedData(
|
||||
await $_getPrefetchedData<Profile, $ProfilesTable,
|
||||
RecentlyPlayedData>(
|
||||
currentTable: table,
|
||||
referencedTable: $$ProfilesTableReferences
|
||||
._recentlyPlayedRefsTable(db),
|
||||
@@ -3254,8 +3634,10 @@ final class $$RecentlyPlayedTableReferences extends BaseReferences<
|
||||
db.recentlyPlayed.profileUsername, db.profiles.username));
|
||||
|
||||
$$ProfilesTableProcessedTableManager get profileUsername {
|
||||
final $_column = $_itemColumn<String>('profile_username')!;
|
||||
|
||||
final manager = $$ProfilesTableTableManager($_db, $_db.profiles)
|
||||
.filter((f) => f.username($_item.profileUsername));
|
||||
.filter((f) => f.username.sqlEquals($_column));
|
||||
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
|
||||
if (item == null) return manager;
|
||||
return ProcessedTableManager(
|
||||
@@ -4697,6 +5079,198 @@ typedef $$SearchHistoryTableProcessedTableManager = ProcessedTableManager<
|
||||
),
|
||||
SearchHistoryData,
|
||||
PrefetchHooks Function()>;
|
||||
typedef $$DetailTrackCacheTableCreateCompanionBuilder
|
||||
= DetailTrackCacheCompanion Function({
|
||||
required String cacheKey,
|
||||
required String parentType,
|
||||
required String parentKey,
|
||||
required String tracksJson,
|
||||
required DateTime lastFetched,
|
||||
required DateTime lastAccessed,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$DetailTrackCacheTableUpdateCompanionBuilder
|
||||
= DetailTrackCacheCompanion Function({
|
||||
Value<String> cacheKey,
|
||||
Value<String> parentType,
|
||||
Value<String> parentKey,
|
||||
Value<String> tracksJson,
|
||||
Value<DateTime> lastFetched,
|
||||
Value<DateTime> lastAccessed,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
class $$DetailTrackCacheTableFilterComposer
|
||||
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
|
||||
$$DetailTrackCacheTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<String> get cacheKey => $composableBuilder(
|
||||
column: $table.cacheKey, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get parentType => $composableBuilder(
|
||||
column: $table.parentType, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get parentKey => $composableBuilder(
|
||||
column: $table.parentKey, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get tracksJson => $composableBuilder(
|
||||
column: $table.tracksJson, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<DateTime> get lastFetched => $composableBuilder(
|
||||
column: $table.lastFetched, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<DateTime> get lastAccessed => $composableBuilder(
|
||||
column: $table.lastAccessed, builder: (column) => ColumnFilters(column));
|
||||
}
|
||||
|
||||
class $$DetailTrackCacheTableOrderingComposer
|
||||
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
|
||||
$$DetailTrackCacheTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnOrderings<String> get cacheKey => $composableBuilder(
|
||||
column: $table.cacheKey, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get parentType => $composableBuilder(
|
||||
column: $table.parentType, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get parentKey => $composableBuilder(
|
||||
column: $table.parentKey, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get tracksJson => $composableBuilder(
|
||||
column: $table.tracksJson, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<DateTime> get lastFetched => $composableBuilder(
|
||||
column: $table.lastFetched, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<DateTime> get lastAccessed => $composableBuilder(
|
||||
column: $table.lastAccessed,
|
||||
builder: (column) => ColumnOrderings(column));
|
||||
}
|
||||
|
||||
class $$DetailTrackCacheTableAnnotationComposer
|
||||
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
|
||||
$$DetailTrackCacheTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
GeneratedColumn<String> get cacheKey =>
|
||||
$composableBuilder(column: $table.cacheKey, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get parentType => $composableBuilder(
|
||||
column: $table.parentType, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get parentKey =>
|
||||
$composableBuilder(column: $table.parentKey, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get tracksJson => $composableBuilder(
|
||||
column: $table.tracksJson, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get lastFetched => $composableBuilder(
|
||||
column: $table.lastFetched, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get lastAccessed => $composableBuilder(
|
||||
column: $table.lastAccessed, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$DetailTrackCacheTableTableManager extends RootTableManager<
|
||||
_$AppDatabase,
|
||||
$DetailTrackCacheTable,
|
||||
DetailTrackCacheData,
|
||||
$$DetailTrackCacheTableFilterComposer,
|
||||
$$DetailTrackCacheTableOrderingComposer,
|
||||
$$DetailTrackCacheTableAnnotationComposer,
|
||||
$$DetailTrackCacheTableCreateCompanionBuilder,
|
||||
$$DetailTrackCacheTableUpdateCompanionBuilder,
|
||||
(
|
||||
DetailTrackCacheData,
|
||||
BaseReferences<_$AppDatabase, $DetailTrackCacheTable,
|
||||
DetailTrackCacheData>
|
||||
),
|
||||
DetailTrackCacheData,
|
||||
PrefetchHooks Function()> {
|
||||
$$DetailTrackCacheTableTableManager(
|
||||
_$AppDatabase db, $DetailTrackCacheTable table)
|
||||
: super(TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
$$DetailTrackCacheTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
$$DetailTrackCacheTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
$$DetailTrackCacheTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback: ({
|
||||
Value<String> cacheKey = const Value.absent(),
|
||||
Value<String> parentType = const Value.absent(),
|
||||
Value<String> parentKey = const Value.absent(),
|
||||
Value<String> tracksJson = const Value.absent(),
|
||||
Value<DateTime> lastFetched = const Value.absent(),
|
||||
Value<DateTime> lastAccessed = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
DetailTrackCacheCompanion(
|
||||
cacheKey: cacheKey,
|
||||
parentType: parentType,
|
||||
parentKey: parentKey,
|
||||
tracksJson: tracksJson,
|
||||
lastFetched: lastFetched,
|
||||
lastAccessed: lastAccessed,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback: ({
|
||||
required String cacheKey,
|
||||
required String parentType,
|
||||
required String parentKey,
|
||||
required String tracksJson,
|
||||
required DateTime lastFetched,
|
||||
required DateTime lastAccessed,
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
DetailTrackCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
parentType: parentType,
|
||||
parentKey: parentKey,
|
||||
tracksJson: tracksJson,
|
||||
lastFetched: lastFetched,
|
||||
lastAccessed: lastAccessed,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
));
|
||||
}
|
||||
|
||||
typedef $$DetailTrackCacheTableProcessedTableManager = ProcessedTableManager<
|
||||
_$AppDatabase,
|
||||
$DetailTrackCacheTable,
|
||||
DetailTrackCacheData,
|
||||
$$DetailTrackCacheTableFilterComposer,
|
||||
$$DetailTrackCacheTableOrderingComposer,
|
||||
$$DetailTrackCacheTableAnnotationComposer,
|
||||
$$DetailTrackCacheTableCreateCompanionBuilder,
|
||||
$$DetailTrackCacheTableUpdateCompanionBuilder,
|
||||
(
|
||||
DetailTrackCacheData,
|
||||
BaseReferences<_$AppDatabase, $DetailTrackCacheTable,
|
||||
DetailTrackCacheData>
|
||||
),
|
||||
DetailTrackCacheData,
|
||||
PrefetchHooks Function()>;
|
||||
|
||||
class $AppDatabaseManager {
|
||||
final _$AppDatabase _db;
|
||||
@@ -4719,4 +5293,6 @@ class $AppDatabaseManager {
|
||||
$$HomeRowCacheTableTableManager(_db, _db.homeRowCache);
|
||||
$$SearchHistoryTableTableManager get searchHistory =>
|
||||
$$SearchHistoryTableTableManager(_db, _db.searchHistory);
|
||||
$$DetailTrackCacheTableTableManager get detailTrackCache =>
|
||||
$$DetailTrackCacheTableTableManager(_db, _db.detailTrackCache);
|
||||
}
|
||||
|
||||
+11
-1
@@ -295,5 +295,15 @@
|
||||
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
||||
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
|
||||
"holdToSync": "Gedrückt halten zum Sync",
|
||||
"swipeToAdjustVolume": "Wischen für Lautstärke"
|
||||
"swipeToAdjustVolume": "Wischen für Lautstärke",
|
||||
|
||||
"aboutAndSupport": "Über & Unterstützung",
|
||||
"supportDevelopment": "Entwicklung unterstützen",
|
||||
"ensembleIsUnofficial": "Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.",
|
||||
"sponsorOnGitHub": "Auf GitHub sponsoren",
|
||||
"buyMeAKoFi": "Kauf mir einen Ko-fi",
|
||||
"aboutEnsemble": "Über Ensemble",
|
||||
"version": "Version",
|
||||
"visitGitHubRepo": "GitHub-Repository besuchen",
|
||||
"reportABug": "Einen Fehler melden"
|
||||
}
|
||||
|
||||
@@ -221,6 +221,36 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"discoveryMixes": "Discovery Mixes",
|
||||
"@discoveryMixes": {
|
||||
"description": "Provider discovery mixes (e.g., Tidal playlists)"
|
||||
},
|
||||
|
||||
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
|
||||
"@discoveryMixesDescription": {
|
||||
"description": "Description for discovery mixes setting"
|
||||
},
|
||||
|
||||
"discoveryRows": "Discovery rows from Music Assistant",
|
||||
"@discoveryRows": {
|
||||
"description": "Label for discovery rows section"
|
||||
},
|
||||
|
||||
"refreshDiscoveryRows": "Refresh available rows",
|
||||
"@refreshDiscoveryRows": {
|
||||
"description": "Button to refresh discovery rows"
|
||||
},
|
||||
|
||||
"noDiscoveryRows": "No discovery rows available",
|
||||
"@noDiscoveryRows": {
|
||||
"description": "Empty state when no discovery rows available"
|
||||
},
|
||||
|
||||
"discoveryRowBadge": "Music Assistant",
|
||||
"@discoveryRowBadge": {
|
||||
"description": "Badge label for discovery rows"
|
||||
},
|
||||
|
||||
"continueListening": "Continue Listening",
|
||||
"@continueListening": {
|
||||
"description": "Home screen section title"
|
||||
@@ -1683,5 +1713,60 @@
|
||||
"selectPlayerToAddToQueue": "Select player to add to queue",
|
||||
"@selectPlayerToAddToQueue": {
|
||||
"description": "Hint for selecting player to add to queue"
|
||||
},
|
||||
|
||||
"aboutAndSupport": "About & Support",
|
||||
"@aboutAndSupport": {
|
||||
"description": "About and support section title"
|
||||
},
|
||||
|
||||
"supportDevelopment": "Support Development",
|
||||
"@supportDevelopment": {
|
||||
"description": "Support development section"
|
||||
},
|
||||
|
||||
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.",
|
||||
"@ensembleIsUnofficial": {
|
||||
"description": "Description about Ensemble being unofficial"
|
||||
},
|
||||
|
||||
"sponsorOnGitHub": "Sponsor on GitHub",
|
||||
"@sponsorOnGitHub": {
|
||||
"description": "GitHub Sponsors button label"
|
||||
},
|
||||
|
||||
"buyMeAKoFi": "Buy me a Ko-fi",
|
||||
"@buyMeAKoFi": {
|
||||
"description": "Ko-fi donation button label"
|
||||
},
|
||||
|
||||
"aboutEnsemble": "About Ensemble",
|
||||
"@aboutEnsemble": {
|
||||
"description": "About Ensemble section"
|
||||
},
|
||||
|
||||
"version": "Version",
|
||||
"@version": {
|
||||
"description": "Version label"
|
||||
},
|
||||
|
||||
"visitGitHubRepo": "Visit GitHub Repository",
|
||||
"@visitGitHubRepo": {
|
||||
"description": "Link to GitHub repo"
|
||||
},
|
||||
|
||||
"reportABug": "Report a Bug",
|
||||
"@reportABug": {
|
||||
"description": "Link to report issues"
|
||||
},
|
||||
|
||||
"noRowsEnabled": "No rows enabled",
|
||||
"@noRowsEnabled": {
|
||||
"description": "Empty state when no home screen rows are enabled"
|
||||
},
|
||||
|
||||
"pullToRefreshHint": "Pull down to refresh or enable rows in settings",
|
||||
"@pullToRefreshHint": {
|
||||
"description": "Hint when home screen is empty"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -491,5 +491,15 @@
|
||||
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
||||
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
|
||||
"holdToSync": "Mantén presionado para sync",
|
||||
"swipeToAdjustVolume": "Desliza para ajustar volumen"
|
||||
"swipeToAdjustVolume": "Desliza para ajustar volumen",
|
||||
|
||||
"aboutAndSupport": "Acerca de y Soporte",
|
||||
"supportDevelopment": "Apoyar el Desarrollo",
|
||||
"ensembleIsUnofficial": "Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.",
|
||||
"sponsorOnGitHub": "Patrocina en GitHub",
|
||||
"buyMeAKoFi": "Cómprame un Ko-fi",
|
||||
"aboutEnsemble": "Acerca de Ensemble",
|
||||
"version": "Versión",
|
||||
"visitGitHubRepo": "Visitar Repositorio GitHub",
|
||||
"reportABug": "Reportar un Error"
|
||||
}
|
||||
|
||||
+11
-1
@@ -1396,5 +1396,15 @@
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"aboutAndSupport": "À propos et Support",
|
||||
"supportDevelopment": "Soutenir le Développement",
|
||||
"ensembleIsUnofficial": "Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.",
|
||||
"sponsorOnGitHub": "Sponsoriser sur GitHub",
|
||||
"buyMeAKoFi": "Offrez-moi un Ko-fi",
|
||||
"aboutEnsemble": "À propos d'Ensemble",
|
||||
"version": "Version",
|
||||
"visitGitHubRepo": "Visiter le Dépôt GitHub",
|
||||
"reportABug": "Signaler un Bug"
|
||||
}
|
||||
|
||||
@@ -359,6 +359,42 @@ abstract class S {
|
||||
/// **'Discover Albums'**
|
||||
String get discoverAlbums;
|
||||
|
||||
/// Provider discovery mixes (e.g., Tidal playlists)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discovery Mixes'**
|
||||
String get discoveryMixes;
|
||||
|
||||
/// Description for discovery mixes setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
|
||||
String get discoveryMixesDescription;
|
||||
|
||||
/// Label for discovery rows section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discovery rows from Music Assistant'**
|
||||
String get discoveryRows;
|
||||
|
||||
/// Button to refresh discovery rows
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh available rows'**
|
||||
String get refreshDiscoveryRows;
|
||||
|
||||
/// Empty state when no discovery rows available
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No discovery rows available'**
|
||||
String get noDiscoveryRows;
|
||||
|
||||
/// Badge label for discovery rows
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Music Assistant'**
|
||||
String get discoveryRowBadge;
|
||||
|
||||
/// Home screen section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2182,6 +2218,72 @@ abstract class S {
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to add to queue'**
|
||||
String get selectPlayerToAddToQueue;
|
||||
|
||||
/// About and support section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About & Support'**
|
||||
String get aboutAndSupport;
|
||||
|
||||
/// Support development section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Support Development'**
|
||||
String get supportDevelopment;
|
||||
|
||||
/// Description about Ensemble being unofficial
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'**
|
||||
String get ensembleIsUnofficial;
|
||||
|
||||
/// GitHub Sponsors button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sponsor on GitHub'**
|
||||
String get sponsorOnGitHub;
|
||||
|
||||
/// Ko-fi donation button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Buy me a Ko-fi'**
|
||||
String get buyMeAKoFi;
|
||||
|
||||
/// About Ensemble section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About Ensemble'**
|
||||
String get aboutEnsemble;
|
||||
|
||||
/// Version label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Version'**
|
||||
String get version;
|
||||
|
||||
/// Link to GitHub repo
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Visit GitHub Repository'**
|
||||
String get visitGitHubRepo;
|
||||
|
||||
/// Link to report issues
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report a Bug'**
|
||||
String get reportABug;
|
||||
|
||||
/// Empty state when no home screen rows are enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No rows enabled'**
|
||||
String get noRowsEnabled;
|
||||
|
||||
/// Hint when home screen is empty
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pull down to refresh or enable rows in settings'**
|
||||
String get pullToRefreshHint;
|
||||
}
|
||||
|
||||
class _SDelegate extends LocalizationsDelegate<S> {
|
||||
|
||||
@@ -151,6 +151,25 @@ class SDe extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Alben entdecken';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Weiterhoren';
|
||||
|
||||
@@ -1186,4 +1205,39 @@ class SDe extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'Über & Unterstützung';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Entwicklung unterstützen';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Auf GitHub sponsoren';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Kauf mir einen Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'Über Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'GitHub-Repository besuchen';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Einen Fehler melden';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -148,6 +148,25 @@ class SEn extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Discover Albums';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continue Listening';
|
||||
|
||||
@@ -1177,4 +1196,39 @@ class SEn extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'About & Support';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Support Development';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Sponsor on GitHub';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Buy me a Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'About Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'Visit GitHub Repository';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Report a Bug';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -152,6 +152,25 @@ class SEs extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Descubrir álbumes';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuar escuchando';
|
||||
|
||||
@@ -1194,4 +1213,39 @@ class SEs extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'Acerca de y Soporte';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Apoyar el Desarrollo';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Patrocina en GitHub';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Cómprame un Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'Acerca de Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Versión';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'Visitar Repositorio GitHub';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Reportar un Error';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -153,6 +153,25 @@ class SFr extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Découvrir des albums';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuer l\'écoute';
|
||||
|
||||
@@ -1203,4 +1222,39 @@ class SFr extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'À propos et Support';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Soutenir le Développement';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Sponsoriser sur GitHub';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Offrez-moi un Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'À propos d\'Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'Visiter le Dépôt GitHub';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Signaler un Bug';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
+145
-80
@@ -31,82 +31,85 @@ late MassivAudioHandler audioHandler;
|
||||
// Global debug logger instance
|
||||
final _logger = DebugLogger();
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize SharedPreferences cache early for performance
|
||||
await SettingsService.initialize();
|
||||
_logger.log('⚡ SharedPreferences cached');
|
||||
|
||||
// Initialize local database
|
||||
await DatabaseService.instance.initialize();
|
||||
_logger.log('💾 Database initialized');
|
||||
|
||||
// Migrate existing ownerName to profile (one-time for existing users)
|
||||
await ProfileService.instance.migrateFromOwnerName();
|
||||
|
||||
// Migrate credentials to secure storage (one-time for existing users)
|
||||
await SettingsService.migrateToSecureStorage();
|
||||
_logger.log('🔐 Secure storage migration complete');
|
||||
|
||||
// Load library from cache for instant startup
|
||||
await SyncService.instance.loadFromCache();
|
||||
_logger.log('📦 Library cache loaded');
|
||||
|
||||
// Create auth manager for streaming headers
|
||||
final authManager = AuthManager();
|
||||
|
||||
// Initialize audio_service with our custom handler
|
||||
audioHandler = await AudioService.init(
|
||||
builder: () => MassivAudioHandler(authManager: authManager),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
|
||||
androidNotificationChannelName: 'Ensemble Audio',
|
||||
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
|
||||
androidNotificationIcon: 'drawable/ic_notification',
|
||||
androidShowNotificationBadge: false,
|
||||
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
||||
),
|
||||
);
|
||||
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
||||
|
||||
// Set preferred orientations
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
// Set initial system UI overlay style based on platform brightness
|
||||
// SystemUIWrapper will update this dynamically when theme changes
|
||||
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
final isDark = platformBrightness == Brightness.dark;
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
// Set up Flutter error boundary to catch and log widget build errors
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
|
||||
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
|
||||
// Still report to Flutter's default handler in debug mode
|
||||
FlutterError.presentError(details);
|
||||
};
|
||||
|
||||
// Catch errors from the platform dispatcher (platform channel errors)
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
|
||||
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
|
||||
return true; // Handled
|
||||
};
|
||||
|
||||
// Wrap app in zone to catch async errors
|
||||
void main() {
|
||||
// All initialization must run inside the same zone as runApp() to avoid
|
||||
// "Zone mismatch" errors from Flutter's binding system
|
||||
runZonedGuarded(
|
||||
() => runApp(const MusicAssistantApp()),
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize SharedPreferences cache early for performance
|
||||
await SettingsService.initialize();
|
||||
_logger.log('⚡ SharedPreferences cached');
|
||||
|
||||
// Initialize local database
|
||||
await DatabaseService.instance.initialize();
|
||||
_logger.log('💾 Database initialized');
|
||||
|
||||
// Migrate existing ownerName to profile (one-time for existing users)
|
||||
await ProfileService.instance.migrateFromOwnerName();
|
||||
|
||||
// Migrate credentials to secure storage (one-time for existing users)
|
||||
await SettingsService.migrateToSecureStorage();
|
||||
_logger.log('🔐 Secure storage migration complete');
|
||||
|
||||
// Load library from cache for instant startup
|
||||
await SyncService.instance.loadFromCache();
|
||||
_logger.log('📦 Library cache loaded');
|
||||
|
||||
// Create auth manager for streaming headers
|
||||
final authManager = AuthManager();
|
||||
|
||||
// Initialize audio_service with our custom handler
|
||||
audioHandler = await AudioService.init(
|
||||
builder: () => MassivAudioHandler(authManager: authManager),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
|
||||
androidNotificationChannelName: 'Ensemble Audio',
|
||||
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
|
||||
androidNotificationIcon: 'drawable/ic_notification',
|
||||
androidShowNotificationBadge: false,
|
||||
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
||||
),
|
||||
);
|
||||
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
||||
|
||||
// Set preferred orientations
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
// Set initial system UI overlay style based on platform brightness
|
||||
// SystemUIWrapper will update this dynamically when theme changes
|
||||
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
final isDark = platformBrightness == Brightness.dark;
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
// Set up Flutter error boundary to catch and log widget build errors
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
|
||||
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
|
||||
// Still report to Flutter's default handler in debug mode
|
||||
FlutterError.presentError(details);
|
||||
};
|
||||
|
||||
// Catch errors from the platform dispatcher (platform channel errors)
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
|
||||
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
|
||||
return true; // Handled
|
||||
};
|
||||
|
||||
runApp(const MusicAssistantApp());
|
||||
},
|
||||
(error, stackTrace) {
|
||||
_logger.error('Uncaught async error: $error', context: 'ZoneError');
|
||||
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
|
||||
@@ -128,6 +131,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
final _hardwareVolumeService = HardwareVolumeService();
|
||||
StreamSubscription? _volumeUpSub;
|
||||
StreamSubscription? _volumeDownSub;
|
||||
StreamSubscription? _absoluteVolumeSub;
|
||||
String? _lastSelectedPlayerId;
|
||||
String? _builtinPlayerId;
|
||||
|
||||
@@ -140,6 +144,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
_musicProvider = MusicAssistantProvider();
|
||||
_themeProvider = ThemeProvider();
|
||||
_localeProvider = LocaleProvider();
|
||||
audioHandler.setProvider(_musicProvider);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initHardwareVolumeControl();
|
||||
// Listen to player selection changes to toggle volume interception
|
||||
@@ -158,6 +163,12 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
|
||||
_adjustVolume(-_volumeStep);
|
||||
});
|
||||
|
||||
// Listen for absolute volume changes from lockscreen/background
|
||||
// (ContentObserver on system STREAM_MUSIC volume)
|
||||
_absoluteVolumeSub = _hardwareVolumeService.onAbsoluteVolumeChange.listen((volume) {
|
||||
_setAbsoluteVolume(volume);
|
||||
});
|
||||
} catch (e, stack) {
|
||||
_logger.error('Hardware volume init failed', context: 'VolumeInit', error: e, stackTrace: stack);
|
||||
}
|
||||
@@ -173,18 +184,41 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
}
|
||||
|
||||
/// Enable/disable volume button interception based on selected player.
|
||||
/// Also manages the volume observer for lockscreen volume routing.
|
||||
Future<void> _updateVolumeInterception() async {
|
||||
final player = _musicProvider.selectedPlayer;
|
||||
final isBuiltinPlayer = _builtinPlayerId != null &&
|
||||
_musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
|
||||
player?.playerId == _builtinPlayerId;
|
||||
await _hardwareVolumeService.setIntercepting(!isBuiltinPlayer);
|
||||
|
||||
// Start/stop volume observer for lockscreen volume changes.
|
||||
// When a remote/group player is active, observe system volume changes
|
||||
// so lockscreen hardware buttons route to the MA player.
|
||||
if (!isBuiltinPlayer && player != null) {
|
||||
// Get the effective volume to sync the system volume HUD
|
||||
final effectiveVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
|
||||
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
|
||||
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
|
||||
: player.volume;
|
||||
await _hardwareVolumeService.startVolumeObserver(initialVolume: effectiveVolume);
|
||||
} else {
|
||||
await _hardwareVolumeService.stopVolumeObserver();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _adjustVolume(int delta) async {
|
||||
final player = _musicProvider.selectedPlayer;
|
||||
if (player == null) return;
|
||||
|
||||
final newVolume = (player.volume + delta).clamp(0, 100);
|
||||
if (newVolume != player.volume) {
|
||||
// Use effective volume for group players (player.volume returns 0
|
||||
// because MA API returns null for group player volume_level)
|
||||
final currentVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
|
||||
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
|
||||
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
|
||||
: player.volume;
|
||||
|
||||
final newVolume = (currentVolume + delta).clamp(0, 100);
|
||||
if (newVolume != currentVolume) {
|
||||
try {
|
||||
await _musicProvider.setVolume(player.playerId, newVolume);
|
||||
} catch (e) {
|
||||
@@ -193,11 +227,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle absolute volume change from lockscreen/background.
|
||||
/// Called when the ContentObserver detects a system volume change
|
||||
/// while a remote/group player is active.
|
||||
Future<void> _setAbsoluteVolume(int volume) async {
|
||||
final player = _musicProvider.selectedPlayer;
|
||||
if (player == null) return;
|
||||
|
||||
// Don't route to builtin player - that's handled by the system directly
|
||||
if (_builtinPlayerId != null && player.playerId == _builtinPlayerId) return;
|
||||
|
||||
try {
|
||||
await _musicProvider.setVolume(player.playerId, volume.clamp(0, 100));
|
||||
} catch (e) {
|
||||
_logger.error('Lockscreen volume change failed', context: 'VolumeControl', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_musicProvider.removeListener(_onProviderChanged);
|
||||
_volumeUpSub?.cancel();
|
||||
_volumeDownSub?.cancel();
|
||||
_absoluteVolumeSub?.cancel();
|
||||
_hardwareVolumeService.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
@@ -329,8 +381,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
builder: (context, child) {
|
||||
// Wrap entire app with global player overlay
|
||||
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
||||
// Wrap entire app with global player overlay.
|
||||
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
|
||||
return Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (context) => GlobalPlayerOverlay(
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
home: const AppStartup(),
|
||||
),
|
||||
@@ -475,6 +536,9 @@ class _AppStartupState extends State<AppStartup> {
|
||||
Future<void> _checkAndConnect() async {
|
||||
final serverUrl = await SettingsService.getServerUrl();
|
||||
|
||||
// DEBUG: Log where server URL is coming from
|
||||
_logger.log('🐛 DEBUG: Server URL from SharedPreferences: $serverUrl');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
@@ -483,6 +547,7 @@ class _AppStartupState extends State<AppStartup> {
|
||||
|
||||
// If we have a saved server URL, attempt auto-connection
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
_logger.log('🐛 DEBUG: Auto-connecting because server URL exists');
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'media_item.dart';
|
||||
|
||||
class RecommendationFolder {
|
||||
final String itemId;
|
||||
final String provider;
|
||||
final String name;
|
||||
final String? uri;
|
||||
final List<MediaItem> items;
|
||||
|
||||
RecommendationFolder({
|
||||
required this.itemId,
|
||||
required this.provider,
|
||||
required this.name,
|
||||
this.uri,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
/// Unique ID for use in row ordering and settings
|
||||
/// Uses 'discovery:' prefix to distinguish from static row IDs
|
||||
String get rowId => 'discovery:$itemId';
|
||||
|
||||
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
|
||||
final itemsJson = json['items'] as List<dynamic>?;
|
||||
final parsedItems = itemsJson
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.map(_parseMediaItemJson)
|
||||
.toList() ?? <MediaItem>[];
|
||||
|
||||
return RecommendationFolder(
|
||||
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
|
||||
provider: json['provider'] as String? ?? 'unknown',
|
||||
name: json['name'] as String? ?? '',
|
||||
uri: json['uri'] as String?,
|
||||
items: parsedItems,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'item_id': itemId,
|
||||
'provider': provider,
|
||||
'name': name,
|
||||
if (uri != null) 'uri': uri,
|
||||
'items': items.map((item) => item.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Polymorphic parser for media items in recommendation folders
|
||||
/// Handles heterogeneous items (playlists, radio, etc.)
|
||||
MediaItem _parseMediaItemJson(Map<String, dynamic> json) {
|
||||
final mediaType = json['media_type'] as String? ?? '';
|
||||
|
||||
switch (mediaType) {
|
||||
case 'playlist':
|
||||
return Playlist.fromJson(json);
|
||||
case 'album':
|
||||
return Album.fromJson(json);
|
||||
case 'artist':
|
||||
return Artist.fromJson(json);
|
||||
case 'track':
|
||||
return Track.fromJson(json);
|
||||
case 'audiobook':
|
||||
return Audiobook.fromJson(json);
|
||||
default:
|
||||
// For radio, podcast, podcast_episode - use MediaItem base class
|
||||
return MediaItem.fromJson(json);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -847,7 +848,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
@@ -935,7 +938,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -972,7 +975,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Spacing.vGap8,
|
||||
// Album Description
|
||||
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
@@ -1109,7 +1112,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1216,7 +1219,29 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
|
||||
],
|
||||
);
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.withMiniPlayer,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.background.withOpacity(0.0),
|
||||
colorScheme.background,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -680,6 +680,19 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No cache yet - show library albums from memory instantly (no API call)
|
||||
final memoryAlbums = provider.getArtistAlbumsFromLibrary(widget.artist.name);
|
||||
if (memoryAlbums.isNotEmpty && mounted) {
|
||||
final libraryAlbums = memoryAlbums.where((a) => a.inLibrary).toList();
|
||||
final providerOnlyAlbums = memoryAlbums.where((a) => !a.inLibrary).toList();
|
||||
setState(() {
|
||||
_albums = libraryAlbums;
|
||||
_providerAlbums = providerOnlyAlbums;
|
||||
_sortAlbums();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch fresh data in background (silent refresh)
|
||||
@@ -690,13 +703,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
|
||||
);
|
||||
|
||||
if (mounted && allAlbums.isNotEmpty) {
|
||||
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
|
||||
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
|
||||
|
||||
// Check if data actually changed
|
||||
final albumsChanged = _albums.length != allAlbums.where((a) => a.inLibrary).length;
|
||||
|
||||
if (albumsChanged || _albums.isEmpty) {
|
||||
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
|
||||
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
|
||||
final changed = _albums.length != libraryAlbums.length ||
|
||||
_providerAlbums.length != providerOnlyAlbums.length;
|
||||
|
||||
if (changed) {
|
||||
setState(() {
|
||||
_albums = libraryAlbums;
|
||||
_providerAlbums = providerOnlyAlbums;
|
||||
@@ -882,11 +896,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
|
||||
backgroundColor: colorScheme.background,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||
final expandedHeight = coverSize + 100;
|
||||
// Responsive cover size: 50% of screen width, clamped between 140-200 (smaller for circular artist image)
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
|
||||
final expandedHeight = coverSize + 45;
|
||||
|
||||
return CustomScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
@@ -942,7 +958,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -959,7 +975,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
|
||||
),
|
||||
),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
Spacing.vGap8,
|
||||
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
onTap: () {
|
||||
@@ -1157,7 +1173,30 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
),
|
||||
// Bottom fade gradient to mask content behind mini player
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.withMiniPlayer,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.background.withOpacity(0.0),
|
||||
colorScheme.background,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'audiobook_detail_screen.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
|
||||
@@ -297,11 +298,13 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||
final expandedHeight = coverSize + 100;
|
||||
// Responsive cover size: 50% of screen width, clamped between 140-200 (matches artist detail)
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
|
||||
final expandedHeight = coverSize + 25;
|
||||
|
||||
return CustomScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
@@ -315,49 +318,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
},
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
actions: [
|
||||
// Three-dot menu with sort/view options
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: Builder(
|
||||
builder: (buttonContext) => FilledButton.tonal(
|
||||
onPressed: () {
|
||||
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
|
||||
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: position,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
|
||||
isFavorite: false,
|
||||
isInLibrary: false,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
showTopRow: false,
|
||||
sortOrder: _sortOrder,
|
||||
onToggleSort: _toggleSortOrder,
|
||||
viewMode: _viewMode,
|
||||
onCycleView: _cycleViewMode,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
size: 20,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -410,22 +370,68 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.authorName,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
// Author info on the left
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.authorName,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.audiobookCount(_audiobooks.length),
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.audiobookCount(_audiobooks.length),
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
// Three-dot Menu Button on the right
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: Builder(
|
||||
builder: (buttonContext) => FilledButton.tonal(
|
||||
onPressed: () {
|
||||
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
|
||||
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: position,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
|
||||
isFavorite: false,
|
||||
isInLibrary: false,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
showTopRow: false,
|
||||
sortOrder: _sortOrder,
|
||||
onToggleSort: _toggleSortOrder,
|
||||
viewMode: _viewMode,
|
||||
onCycleView: _cycleViewMode,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
size: 25,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -448,6 +454,28 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
_buildAudiobookSliver(),
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.withMiniPlayer,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withOpacity(0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -68,53 +68,70 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
markDetailScreenEntered(context);
|
||||
_extractColors(); // Extract colors immediately - async so won't block
|
||||
}
|
||||
// Load full audiobook details with chapters
|
||||
_loadAudiobookDetails();
|
||||
});
|
||||
|
||||
// Defer detail loading until after hero animation completes
|
||||
// This prevents setState with new data during animation → jank
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
_loadAudiobookDetails();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAudiobookDetails() async {
|
||||
if (_isLoadingDetails) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingDetails = true;
|
||||
});
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// 1. Show cached data immediately (if available)
|
||||
final cached = maProvider.getCachedAudiobookDetail(
|
||||
widget.audiobook.provider,
|
||||
widget.audiobook.itemId,
|
||||
);
|
||||
if (cached != null && mounted) {
|
||||
_logger.log('📚 Showing cached audiobook: ${cached.name}, chapters: ${cached.chapters?.length ?? 0}');
|
||||
setState(() {
|
||||
_fullAudiobook = cached;
|
||||
_isLoadingDetails = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoadingDetails = true;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Fetch fresh data in background
|
||||
try {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final fullBook = await maProvider.getAudiobookDetailsWithCache(
|
||||
widget.audiobook.provider,
|
||||
widget.audiobook.itemId,
|
||||
forceRefresh: cached != null,
|
||||
);
|
||||
|
||||
if (maProvider.api != null) {
|
||||
final fullBook = await maProvider.api!.getAudiobookDetails(
|
||||
widget.audiobook.provider,
|
||||
widget.audiobook.itemId,
|
||||
);
|
||||
|
||||
if (mounted && fullBook != null) {
|
||||
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
|
||||
setState(() {
|
||||
_fullAudiobook = fullBook;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
if (mounted && fullBook != null) {
|
||||
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
|
||||
setState(() {
|
||||
_fullAudiobook = fullBook;
|
||||
_isLoadingDetails = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error loading audiobook details: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDetails = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted && _isLoadingDetails) {
|
||||
setState(() {
|
||||
_isLoadingDetails = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractColors() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
|
||||
final imageUrl = maProvider.getImageUrl(_audiobook, size: 512);
|
||||
if (imageUrl == null) return;
|
||||
|
||||
try {
|
||||
@@ -480,7 +497,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
|
||||
final providerImageUrl = maProvider.getImageUrl(_audiobook, size: 512);
|
||||
// Use initialImageUrl as fallback for seamless hero animation
|
||||
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
|
||||
|
||||
@@ -523,7 +540,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
@@ -652,7 +671,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -929,7 +948,29 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||
],
|
||||
);
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.withMiniPlayer,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withOpacity(0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -44,24 +44,30 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
// Extracted colors from book covers
|
||||
List<Color> _extractedColors = [];
|
||||
|
||||
// Cover URLs - start with initial covers, update when books load
|
||||
// Cover URLs - set once from initialCovers or first book load, then frozen
|
||||
List<String> _coverUrls = [];
|
||||
|
||||
// Cached cover widget - built once, never rebuilt
|
||||
Widget? _cachedCoverWidget;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Use initial covers immediately for smooth Hero animation
|
||||
if (widget.initialCovers != null && widget.initialCovers!.isNotEmpty) {
|
||||
_coverUrls = List.from(widget.initialCovers!);
|
||||
// Start color extraction from initial covers
|
||||
_extractColorsFromUrls(widget.initialCovers!);
|
||||
}
|
||||
_loadViewPreferences();
|
||||
// Defer loading until after first frame to ensure UI renders first
|
||||
// Defer color extraction and loading until after hero animation completes
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_loadSeriesBooks();
|
||||
}
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
if (_coverUrls.isNotEmpty) {
|
||||
_extractColorsFromUrls(_coverUrls);
|
||||
}
|
||||
_loadSeriesBooks();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,35 +164,68 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
|
||||
Future<void> _loadSeriesBooks() async {
|
||||
_logger.log('📚 SeriesScreen _loadSeriesBooks START');
|
||||
try {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// 1. Show cached data immediately (if available)
|
||||
final cached = maProvider.getCachedSeriesAudiobooks(widget.series.id);
|
||||
if (cached != null && cached.isNotEmpty && mounted) {
|
||||
_logger.log('📚 Showing ${cached.length} cached series books');
|
||||
_applyBooks(cached, maProvider);
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
_logger.log('📚 SeriesScreen setState done, getting provider...');
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
_logger.log('📚 SeriesScreen got provider, api=${maProvider.api != null}');
|
||||
}
|
||||
|
||||
// 2. Fetch fresh data in background
|
||||
try {
|
||||
if (maProvider.api == null) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.notConnected;
|
||||
_isLoading = false;
|
||||
});
|
||||
if (cached == null || cached.isEmpty) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.notConnected;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.log('📚 SeriesScreen calling getSeriesAudiobooks: path=${widget.series.id}');
|
||||
final books = await maProvider.api!.getSeriesAudiobooks(widget.series.id);
|
||||
final books = await maProvider.getSeriesAudiobooksWithCache(
|
||||
widget.series.id,
|
||||
forceRefresh: cached != null && cached.isNotEmpty,
|
||||
);
|
||||
_logger.log('📚 SeriesScreen got ${books.length} books');
|
||||
|
||||
// Debug: Log sequence data for each book
|
||||
for (final book in books) {
|
||||
_logger.log('📚 Book: "${book.name}" | position=${book.position} | browseOrder=${book.browseOrder} | sortName=${book.sortName} | seq=${book.seriesSequence} | metadata.series=${book.metadata?['series']}');
|
||||
if (mounted && books.isNotEmpty) {
|
||||
final changed = _audiobooks.length != books.length;
|
||||
if (changed || _audiobooks.isEmpty) {
|
||||
_applyBooks(books, maProvider);
|
||||
}
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_logger.log('📚 SeriesScreen error: $e');
|
||||
_logger.log('📚 SeriesScreen stack: $stack');
|
||||
if (mounted && _audiobooks.isEmpty) {
|
||||
setState(() {
|
||||
_error = 'Failed to load books: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Update cover URLs from loaded books
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyBooks(List<Audiobook> books, MusicAssistantProvider maProvider) {
|
||||
// Only populate covers if we have none yet (initialCovers was empty)
|
||||
final needsCovers = _coverUrls.isEmpty;
|
||||
|
||||
setState(() {
|
||||
_audiobooks = books;
|
||||
_sortAudiobooks();
|
||||
if (needsCovers) {
|
||||
final newCovers = <String>[];
|
||||
for (final book in books.take(9)) {
|
||||
final imageUrl = maProvider.getImageUrl(book);
|
||||
@@ -194,34 +233,15 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
newCovers.add(imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_audiobooks = books;
|
||||
_sortAudiobooks(); // Sort inside setState so UI updates
|
||||
// Update covers if we got new ones (may have more than initial)
|
||||
if (newCovers.isNotEmpty) {
|
||||
_coverUrls = newCovers;
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
// Debug: Log sorted order
|
||||
_logger.log('📚 Sorted order ($_sortOrder):');
|
||||
for (final book in _audiobooks) {
|
||||
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
|
||||
if (newCovers.isNotEmpty) {
|
||||
_coverUrls = newCovers;
|
||||
}
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Extract colors from book covers asynchronously
|
||||
_extractCoverColors(maProvider);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_logger.log('📚 SeriesScreen error: $e');
|
||||
_logger.log('📚 SeriesScreen stack: $stack');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Failed to load books: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
if (needsCovers && _extractedColors.isEmpty) {
|
||||
_extractCoverColors(maProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +303,9 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
// App bar with series cover collage (matches audiobook detail screen)
|
||||
SliverAppBar(
|
||||
@@ -301,25 +323,31 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
// Series cover collage - responsive size
|
||||
Hero(
|
||||
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
// Shadow container outside Hero to avoid widget tree mismatch
|
||||
Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Hero child matches source: RepaintBoundary > AspectRatio > ClipRRect
|
||||
child: Hero(
|
||||
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
||||
child: RepaintBoundary(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _buildSeriesCover(colorScheme, maProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _buildSeriesCover(colorScheme, maProvider),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -457,7 +485,29 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.withMiniPlayer,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withOpacity(0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -491,6 +541,9 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Return cached cover widget if already built (prevents rebuilds from unrelated setState)
|
||||
if (_cachedCoverWidget != null) return _cachedCoverWidget!;
|
||||
|
||||
final covers = _coverUrls;
|
||||
|
||||
// Dynamic grid size based on number of covers (matches library screen)
|
||||
@@ -514,7 +567,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
}
|
||||
|
||||
// Build grid using Column/Row for proper sizing (no scrolling issues)
|
||||
return Column(
|
||||
// Cache the widget so it never rebuilds from unrelated setState calls
|
||||
_cachedCoverWidget = Column(
|
||||
children: List.generate(gridSize, (row) {
|
||||
return Expanded(
|
||||
child: Row(
|
||||
@@ -525,8 +579,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
return Expanded(
|
||||
child: coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
key: ValueKey(coverUrl),
|
||||
imageUrl: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Container(
|
||||
@@ -548,6 +605,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
);
|
||||
}),
|
||||
);
|
||||
return _cachedCoverWidget!;
|
||||
}
|
||||
|
||||
/// Builds an empty cell with either a solid color or a nested grid
|
||||
@@ -665,6 +723,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import 'new_home_screen.dart';
|
||||
import 'new_library_screen.dart';
|
||||
@@ -19,6 +22,7 @@ class HomeScreen extends StatefulWidget {
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final GlobalKey<SearchScreenState> _searchScreenKey = GlobalKey<SearchScreenState>();
|
||||
DateTime? _lastBackPress;
|
||||
final _cachedNavColor = _CachedNavColor();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -105,7 +109,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
// No bottomNavigationBar here - it's now in GlobalPlayerOverlay
|
||||
// extendBody: true preserves the existing layout where content fills the
|
||||
// full screen and screens manage their own bottom padding (BottomSpacing).
|
||||
// Without this, Scaffold would constrain the body above the nav bar,
|
||||
// causing double bottom spacing.
|
||||
extendBody: true,
|
||||
// BottomNavigationBar requires an Overlay ancestor (for Tooltip);
|
||||
// Scaffold inside Navigator provides one. Previously it was in
|
||||
// GlobalPlayerOverlay's Stack (outside Navigator) causing crashes.
|
||||
bottomNavigationBar: _buildBottomNavigationBar(context, colorScheme),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Home and Library use IndexedStack for state preservation
|
||||
@@ -131,4 +143,162 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the bottom navigation bar with adaptive colors and player expansion animation.
|
||||
/// Moved here from GlobalPlayerOverlay so it has proper Overlay ancestry
|
||||
/// (required by BottomNavigationBar's internal Tooltip widgets).
|
||||
Widget? _buildBottomNavigationBar(BuildContext context, ColorScheme colorScheme) {
|
||||
// Hide nav bar when not connected and no cached players
|
||||
final showNavBar = context.select<MusicAssistantProvider, bool>(
|
||||
(p) => p.isConnected || p.hasCachedPlayers,
|
||||
);
|
||||
if (!showNavBar) return null;
|
||||
|
||||
return Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return ValueListenableBuilder<PlayerExpansionState>(
|
||||
valueListenable: playerExpansionNotifier,
|
||||
builder: (context, expansionState, _) {
|
||||
// Nav bar color logic - only use adaptive colors when:
|
||||
// 1. Player is expanding/expanded, OR
|
||||
// 2. On a detail screen (isOnDetailScreen)
|
||||
// On home screen with collapsed player: always use default theme colors
|
||||
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
|
||||
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
|
||||
|
||||
// Nav bar background color
|
||||
final Color navBgColor;
|
||||
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
|
||||
// Player is expanding - blend from surface to player's adaptive color
|
||||
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
|
||||
} else if (useAdaptiveColors) {
|
||||
// On a detail screen - use adaptive surface color for nav bar
|
||||
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
|
||||
navBgColor = adaptiveBg ?? colorScheme.surface;
|
||||
} else {
|
||||
// Home screen with collapsed player - use default surface color
|
||||
navBgColor = colorScheme.surface;
|
||||
}
|
||||
|
||||
// Icon color - only use adaptive colors when appropriate
|
||||
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
|
||||
? themeProvider.adaptiveColors!.primary
|
||||
: colorScheme.primary;
|
||||
|
||||
// Blend icon color with player's primary color during expansion
|
||||
Color sourceColor = baseSourceColor;
|
||||
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
|
||||
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
|
||||
}
|
||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||
|
||||
// Fade out and slide down nav bar as player expands
|
||||
// Use IgnorePointer when faded to prevent accidental taps
|
||||
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
|
||||
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: navOpacity < 0.1,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, navSlideDown),
|
||||
child: Opacity(
|
||||
opacity: navOpacity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: navBgColor,
|
||||
boxShadow: expansionState.progress < 0.5
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
// Clear adaptive colors when switching tabs (detail screen colors shouldn't persist)
|
||||
themeProvider.clearAdaptiveColors();
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
activeIcon: const Icon(Icons.home_rounded),
|
||||
label: S.of(context)!.home,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Symbols.book_2, fill: 0),
|
||||
activeIcon: const Icon(Symbols.book_2, fill: 1),
|
||||
label: S.of(context)!.library,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
activeIcon: const Icon(Icons.search_rounded),
|
||||
label: S.of(context)!.search,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
activeIcon: const Icon(Icons.settings_rounded),
|
||||
label: S.of(context)!.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached color with contrast adjustment for nav bar icon colors.
|
||||
/// Avoids expensive HSL conversions during scroll/animation.
|
||||
class _CachedNavColor {
|
||||
Color? _sourceColor;
|
||||
bool? _isDark;
|
||||
Color? _adjustedColor;
|
||||
|
||||
Color getAdjustedColor(Color sourceColor, bool isDark) {
|
||||
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
|
||||
return _adjustedColor!;
|
||||
}
|
||||
|
||||
var navSelectedColor = sourceColor;
|
||||
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
|
||||
final hsl = HSLColor.fromColor(navSelectedColor);
|
||||
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
|
||||
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
|
||||
final hsl = HSLColor.fromColor(navSelectedColor);
|
||||
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
|
||||
}
|
||||
|
||||
_sourceColor = sourceColor;
|
||||
_isDark = isDark;
|
||||
_adjustedColor = navSelectedColor;
|
||||
return navSelectedColor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/sync_service.dart';
|
||||
@@ -16,6 +18,7 @@ import '../widgets/series_row.dart';
|
||||
import '../widgets/playlist_row.dart';
|
||||
import '../widgets/radio_station_row.dart';
|
||||
import '../widgets/podcast_row.dart';
|
||||
import '../widgets/discovery_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -27,9 +30,9 @@ class NewHomeScreen extends StatefulWidget {
|
||||
State<NewHomeScreen> createState() => _NewHomeScreenState();
|
||||
}
|
||||
|
||||
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
|
||||
static final _logger = DebugLogger();
|
||||
Key _refreshKey = UniqueKey();
|
||||
final ValueNotifier<int> _refreshSignal = ValueNotifier<int>(0);
|
||||
// Main rows (default on)
|
||||
bool _showRecentAlbums = true;
|
||||
bool _showDiscoverArtists = true;
|
||||
@@ -47,6 +50,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showDiscoverSeries = false;
|
||||
// Row order (loaded from settings)
|
||||
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||
// Discovery folders (dynamic rows from provider recommendations)
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
// Discovery row preferences (itemId -> enabled)
|
||||
Map<String, bool> _discoveryRowEnabled = {};
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -56,25 +63,82 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
SyncStatus? _lastSyncStatus;
|
||||
int _lastArtistCount = 0;
|
||||
MusicAssistantProvider? _providerListeningTo;
|
||||
int _lastHomeRefreshCounter = 0; // Track home refresh counter to force row refreshes
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_loadSettings();
|
||||
// Listen to sync completion to refresh favorite rows when data becomes available
|
||||
SyncService.instance.addListener(_onSyncChanged);
|
||||
_lastSyncStatus = SyncService.instance.status;
|
||||
_checkInitialFavoriteState();
|
||||
|
||||
// Always listen to provider for home refresh counter changes
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
_lastHomeRefreshCounter = provider.homeRefreshCounter;
|
||||
_providerListeningTo = provider;
|
||||
provider.addListener(_onProviderChanged);
|
||||
|
||||
// Reload settings only when switching back to home from settings tab
|
||||
navigationProvider.addListener(_onNavigationChanged);
|
||||
}
|
||||
|
||||
int _lastNavIndex = 0;
|
||||
|
||||
void _onNavigationChanged() {
|
||||
final newIndex = navigationProvider.selectedIndex;
|
||||
// Reload preferences when leaving settings tab (3) for home tab (0)
|
||||
if (_lastNavIndex == 3 && newIndex == 0) {
|
||||
_reloadPreferencesOnly();
|
||||
}
|
||||
_lastNavIndex = newIndex;
|
||||
}
|
||||
|
||||
/// Reload only local preferences (no API calls). Used when switching back from settings.
|
||||
Future<void> _reloadPreferencesOnly() async {
|
||||
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
|
||||
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
|
||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
_showDiscoverArtists = showDiscArtists;
|
||||
_showDiscoverAlbums = showDiscAlbums;
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showFavoritePodcasts = showFavPodcasts;
|
||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
navigationProvider.removeListener(_onNavigationChanged);
|
||||
SyncService.instance.removeListener(_onSyncChanged);
|
||||
// Clean up provider listener if attached
|
||||
// Clean up provider listener
|
||||
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_refreshSignal.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -96,21 +160,31 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
});
|
||||
}
|
||||
|
||||
/// Called when MusicAssistantProvider changes (for cache load detection)
|
||||
/// Called when MusicAssistantProvider changes (for cache load detection and home refresh)
|
||||
void _onProviderChanged() {
|
||||
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
|
||||
final currentCount = _providerListeningTo!.artists.length;
|
||||
// If artists went from 0 to non-zero, refresh
|
||||
if (_lastArtistCount == 0 && currentCount > 0) {
|
||||
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
_providerListeningTo!.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
if (!mounted || _providerListeningTo == null) return;
|
||||
|
||||
final provider = _providerListeningTo!;
|
||||
final currentRefreshCounter = provider.homeRefreshCounter;
|
||||
|
||||
// Check if home refresh counter changed (indicates authentication completed and cache was invalidated)
|
||||
if (currentRefreshCounter != _lastHomeRefreshCounter) {
|
||||
_logger.log('🔄 Home: refresh counter changed ($_lastHomeRefreshCounter -> $currentRefreshCounter), refreshing all rows');
|
||||
_lastHomeRefreshCounter = currentRefreshCounter;
|
||||
_refreshSignal.value++;
|
||||
return; // Don't process other refresh conditions in this cycle
|
||||
}
|
||||
|
||||
// Check for artists cache load (when favorites were empty on load)
|
||||
if (_hadEmptyFavoritesOnLoad) {
|
||||
final currentCount = provider.artists.length;
|
||||
if (_lastArtistCount == 0 && currentCount > 0) {
|
||||
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
_refreshSignal.value++;
|
||||
}
|
||||
_lastArtistCount = currentCount;
|
||||
}
|
||||
_lastArtistCount = currentCount;
|
||||
}
|
||||
|
||||
/// Called when SyncService status changes
|
||||
@@ -126,21 +200,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
_refreshSignal.value++;
|
||||
}
|
||||
}
|
||||
_lastSyncStatus = newStatus;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Reload settings when app resumes (coming back from settings)
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_loadSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||
@@ -156,6 +221,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -171,8 +237,63 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
});
|
||||
}
|
||||
|
||||
// Load discovery folders dynamically (after settings load)
|
||||
// Skip if already loaded to avoid unnecessary rebuilds on settings reload
|
||||
if (_discoveryFolders.isEmpty) {
|
||||
await _loadDiscoveryFolders(forceRefresh: false);
|
||||
} else {
|
||||
// Just re-sync the row order with current enabled state (no API call)
|
||||
setState(() {
|
||||
_syncDiscoveryRowOrder(_discoveryFolders);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDiscoveryFolders({bool forceRefresh = false}) async {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
var folders = await provider.getDiscoveryFoldersWithCache();
|
||||
// If cached result is empty, force a refresh to get from API/database
|
||||
if (folders.isEmpty && !forceRefresh) {
|
||||
_logger.log('🔄 Discovery: Cached folders empty, forcing refresh...');
|
||||
folders = await provider.getDiscoveryFoldersWithCache(forceRefresh: true);
|
||||
}
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryFolders = folders;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
_syncDiscoveryRowOrder(folders);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load discovery folders: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync discovery row order without calling setState. Only adds new folders, never removes.
|
||||
/// Visibility is controlled by _discoveryRowEnabled / _isRowEnabled().
|
||||
bool _syncDiscoveryRowOrder(List<RecommendationFolder> folders) {
|
||||
bool didModify = false;
|
||||
|
||||
for (final folder in folders) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
if (!_homeRowOrder.contains(discoveryRowId)) {
|
||||
_homeRowOrder.add(discoveryRowId);
|
||||
didModify = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (didModify) {
|
||||
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||
}
|
||||
return didModify;
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
@@ -187,9 +308,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
await _loadSettings();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
_refreshSignal.value++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,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],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -331,6 +473,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
|
||||
/// Check if a specific row is enabled
|
||||
bool _isRowEnabled(String rowId) {
|
||||
// Discovery folders use the pattern 'discovery:{itemId}'
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
// Default to true if not explicitly set
|
||||
return _discoveryRowEnabled[itemId] ?? false;
|
||||
}
|
||||
switch (rowId) {
|
||||
case 'recent-albums': return _showRecentAlbums;
|
||||
case 'discover-artists': return _showDiscoverArtists;
|
||||
@@ -355,15 +503,18 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
builder: (context, constraints) {
|
||||
// Each row is always 1/3 of screen height
|
||||
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
|
||||
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||
// With extendBody: true, constraints include the area behind the nav bar.
|
||||
// Subtract mini player overlay space (mini player + its margins sit above the nav bar).
|
||||
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0; // 84 + 22 = 106
|
||||
final availableHeight = constraints.maxHeight - miniPlayerSpace;
|
||||
|
||||
// Account for margins between rows (2px each)
|
||||
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
|
||||
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
|
||||
// Account for margins between rows (2px each, 2 margins for 3 rows).
|
||||
// Always use the 3-row margin calculation so rowHeight stays stable
|
||||
// regardless of how many rows are currently enabled (avoids jitter on reload).
|
||||
const marginSize = 2.0;
|
||||
final enabledRows = _countEnabledRows();
|
||||
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
|
||||
final rowHeight = (availableHeight - marginsInView) / 3;
|
||||
const marginsInView = 2 * marginSize; // always 2 margins for 3-row layout
|
||||
final rowHeight = (availableHeight - marginsInView) / 3 + 2;
|
||||
|
||||
// Use Android 12+ stretch overscroll effect
|
||||
return NotificationListener<ScrollNotification>(
|
||||
@@ -385,11 +536,18 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
|
||||
child: Column(
|
||||
key: _refreshKey,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildOrderedRows(provider, rowHeight),
|
||||
// For ≤3 rows: no padding needed (rows fit within viewport)
|
||||
// For 4+ rows: pad so last row scrolls above mini player
|
||||
padding: enabledRows >= 4
|
||||
? EdgeInsets.only(bottom: miniPlayerSpace)
|
||||
: EdgeInsets.zero,
|
||||
child: SizedBox(
|
||||
// Ensure minimum height for pull-to-refresh when empty
|
||||
height: enabledRows == 0 ? availableHeight : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildOrderedRows(provider, rowHeight, enabledRows == 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -399,9 +557,51 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
}
|
||||
|
||||
/// Build rows in the user's configured order
|
||||
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
|
||||
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight, bool isEmpty) {
|
||||
final rows = <Widget>[];
|
||||
|
||||
// Show empty state with refresh hint when no rows are enabled
|
||||
if (isEmpty) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
rows.add(
|
||||
SizedBox.expand(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.home_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
S.of(context)!.noRowsEnabled,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.pullToRefreshHint,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
for (final rowId in _homeRowOrder) {
|
||||
final widget = _buildRowWidget(rowId, provider, rowHeight);
|
||||
if (widget != null) {
|
||||
@@ -426,6 +626,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
title: S.of(context)!.recentlyPlayed,
|
||||
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
||||
getCachedAlbums: () => provider.getCachedRecentAlbums(),
|
||||
refreshSignal: _refreshSignal,
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'discover-artists':
|
||||
@@ -435,6 +636,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
title: S.of(context)!.discoverArtists,
|
||||
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
||||
getCachedArtists: () => provider.getCachedDiscoverArtists(),
|
||||
refreshSignal: _refreshSignal,
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'discover-albums':
|
||||
@@ -444,6 +646,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
title: S.of(context)!.discoverAlbums,
|
||||
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
||||
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
|
||||
refreshSignal: _refreshSignal,
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'continue-listening':
|
||||
@@ -479,6 +682,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
key: const ValueKey('favorite-albums'),
|
||||
title: S.of(context)!.favoriteAlbums,
|
||||
loadAlbums: () => provider.getFavoriteAlbums(),
|
||||
refreshSignal: _refreshSignal,
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-artists':
|
||||
@@ -487,6 +691,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
key: const ValueKey('favorite-artists'),
|
||||
title: S.of(context)!.favoriteArtists,
|
||||
loadArtists: () => provider.getFavoriteArtists(),
|
||||
refreshSignal: _refreshSignal,
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-tracks':
|
||||
@@ -525,6 +730,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
default:
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final folderId = rowId.substring('discovery:'.length);
|
||||
// Check per-row preference
|
||||
final isEnabled = _discoveryRowEnabled[folderId] ?? false;
|
||||
if (!isEnabled) return null;
|
||||
|
||||
try {
|
||||
final folder = _discoveryFolders.firstWhere(
|
||||
(f) => f.itemId == folderId,
|
||||
);
|
||||
return DiscoveryRow(
|
||||
key: ValueKey(rowId),
|
||||
title: folder.name,
|
||||
loadItems: () => provider.getDiscoveryFolderItems(folderId),
|
||||
getCachedItems: () => provider.getCachedDiscoveryFolderItems(folderId),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
} catch (e) {
|
||||
// Folder not found, skip this row
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3410,7 +3410,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
|
||||
// ============ PODCASTS TAB ============
|
||||
// Consumer wrapper avoids using context.select inside PageView.builder's
|
||||
// sliver context, which would rebuild the entire list instead of individual items.
|
||||
Widget _buildPodcastsTab(BuildContext context, S l10n) {
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, _) {
|
||||
return _buildPodcastsTabContent(context, l10n, maProvider);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPodcastsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
// PERF: Use select() to only rebuild when podcasts, loading state, or enabled providers changes
|
||||
@@ -3714,7 +3724,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
|
||||
// ============ RADIO TAB ============
|
||||
// Consumer wrapper avoids using context.select inside PageView.builder's
|
||||
// sliver context, which would rebuild the entire list instead of individual items.
|
||||
Widget _buildRadioStationsTab(BuildContext context, S l10n) {
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, _) {
|
||||
return _buildRadioTabContent(context, l10n, maProvider);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRadioTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
// PERF: Use select() to only rebuild when radio stations, loading state, or enabled providers changes
|
||||
@@ -3964,7 +3984,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
|
||||
// ============ ARTISTS TAB ============
|
||||
// Consumer wrapper avoids using context.select inside PageView.builder's
|
||||
// sliver context, which would rebuild the entire list instead of individual items.
|
||||
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, _) {
|
||||
return _buildArtistsTabContent(context, l10n, maProvider);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtistsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
|
||||
// Use select to only rebuild when specific fields change
|
||||
// SyncService changes are handled by _onSyncServiceChanged listener
|
||||
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
|
||||
@@ -4201,7 +4231,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
|
||||
// ============ ALBUMS TAB ============
|
||||
// Consumer wrapper avoids using context.select inside PageView.builder's
|
||||
// sliver context, which would rebuild the entire list instead of individual items.
|
||||
Widget _buildAlbumsTab(BuildContext context, S l10n) {
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, _) {
|
||||
return _buildAlbumsTabContent(context, l10n, maProvider);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
|
||||
// Use select to only rebuild when specific fields change
|
||||
// SyncService changes are handled by _onSyncServiceChanged listener
|
||||
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
|
||||
@@ -6039,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(
|
||||
@@ -6070,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 {
|
||||
@@ -797,7 +805,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
@@ -882,7 +892,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1052,12 +1062,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 12, right: 20),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Track number
|
||||
SizedBox(
|
||||
width: 28,
|
||||
width: 24,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
@@ -1066,7 +1077,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 16),
|
||||
// Track artwork
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -1115,25 +1126,28 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (HiResBadge.getTooltip(track) != null) ...[
|
||||
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (track.duration != null)
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
trailing: Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (HiResBadge.getTooltip(track) != null) ...[
|
||||
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (track.duration != null)
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () => _playTrack(index),
|
||||
),
|
||||
@@ -1144,6 +1158,28 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
),
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.withMiniPlayer,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withOpacity(0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -61,7 +61,15 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
markDetailScreenEntered(context);
|
||||
_extractColors(); // Extract colors immediately - async so won't block
|
||||
}
|
||||
_loadEpisodes();
|
||||
});
|
||||
|
||||
// Defer episode loading until after hero animation completes
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
_loadEpisodes();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -380,54 +388,62 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
Future<void> _loadEpisodes() async {
|
||||
if (_isLoadingEpisodes) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingEpisodes = true;
|
||||
});
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// 1. Show cached episodes immediately (if available)
|
||||
final cached = maProvider.getCachedPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
if (cached != null && cached.isNotEmpty && mounted) {
|
||||
_logger.log('🎙️ Showing ${cached.length} cached episodes for ${widget.podcast.name}');
|
||||
setState(() {
|
||||
_episodes = cached;
|
||||
_sortEpisodes();
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = true;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Fetch fresh data in background
|
||||
try {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api != null) {
|
||||
List<MediaItem> episodes = [];
|
||||
List<MediaItem> episodes = [];
|
||||
|
||||
// Try loading with the podcast's own ID and provider first
|
||||
try {
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Primary episode load failed: $e');
|
||||
}
|
||||
// Try loading with the podcast's own ID and provider first
|
||||
episodes = await maProvider.getPodcastEpisodesWithCache(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
forceRefresh: cached != null && cached.isNotEmpty,
|
||||
);
|
||||
|
||||
// If that failed and we have provider mappings, try those
|
||||
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
|
||||
for (final mapping in widget.podcast.providerMappings!) {
|
||||
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
|
||||
try {
|
||||
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
mapping.itemId,
|
||||
provider: mapping.providerInstance,
|
||||
);
|
||||
if (episodes.isNotEmpty) {
|
||||
_logger.log('🎙️ Loaded episodes via provider mapping');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Provider mapping failed: $e');
|
||||
// If that failed and we have provider mappings, try those
|
||||
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
|
||||
for (final mapping in widget.podcast.providerMappings!) {
|
||||
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
|
||||
try {
|
||||
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
|
||||
episodes = await maProvider.getPodcastEpisodesWithCache(
|
||||
mapping.itemId,
|
||||
provider: mapping.providerInstance,
|
||||
);
|
||||
if (episodes.isNotEmpty) {
|
||||
_logger.log('🎙️ Loaded episodes via provider mapping');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Provider mapping failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
if (mounted && episodes.isNotEmpty) {
|
||||
final changed = _episodes.length != episodes.length;
|
||||
if (changed || _episodes.isEmpty) {
|
||||
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
|
||||
// Debug: Log first episode metadata to see available fields
|
||||
if (episodes.isNotEmpty) {
|
||||
final first = episodes.first;
|
||||
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
|
||||
_logger.log('🎙️ First episode metadata: ${first.metadata}');
|
||||
}
|
||||
setState(() {
|
||||
_episodes = episodes;
|
||||
_sortEpisodes();
|
||||
@@ -436,12 +452,12 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Error loading episodes: $e');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted && _isLoadingEpisodes) {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,7 +670,9 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
@@ -736,7 +754,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -1109,6 +1127,28 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.withMiniPlayer,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withOpacity(0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -255,7 +255,9 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
|
||||
final coverSize = (constraints.maxWidth * 0.6).clamp(180.0, 280.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
@@ -330,7 +332,7 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
|
||||
// Header content with title, podcast name, metadata, and actions
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -495,6 +497,28 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.withMiniPlayer,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withOpacity(0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+122
-61
@@ -108,6 +108,9 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
};
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
// Race condition fix: Track the active search request ID
|
||||
int _currentSearchId = 0;
|
||||
bool _hasPendingSearch = false;
|
||||
// PERF: Use ValueNotifier to avoid full screen rebuild on filter change
|
||||
final ValueNotifier<String> _activeFilterNotifier = ValueNotifier('all');
|
||||
String? _searchError;
|
||||
@@ -311,6 +314,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
|
||||
void requestFocus() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSearchBarVisible = true;
|
||||
_lastVerticalScrollOffset = 0;
|
||||
});
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
@@ -359,6 +366,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
void _onSearchChanged(String query) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(Timings.searchDebounce, () {
|
||||
// Race condition fix: Check mounted before proceeding
|
||||
if (!mounted) return;
|
||||
_performSearch(query, keepFocus: true);
|
||||
});
|
||||
}
|
||||
@@ -375,6 +384,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Race condition fix: Generate unique ID for this search
|
||||
final searchId = ++_currentSearchId;
|
||||
_hasPendingSearch = true;
|
||||
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_searchError = null;
|
||||
@@ -400,6 +413,11 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
_logger.log('Global radio search failed: $e');
|
||||
}
|
||||
// Race condition fix: Abort if a newer search has started
|
||||
if (!mounted || searchId != _currentSearchId) {
|
||||
_logger.log('⏭️ Ignoring stale radio search results for "$query"');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Combine and deduplicate by name
|
||||
@@ -428,6 +446,11 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
} catch (e) {
|
||||
_logger.log('Global podcast search failed: $e');
|
||||
}
|
||||
// Race condition fix: Abort if a newer search has started
|
||||
if (!mounted || searchId != _currentSearchId) {
|
||||
_logger.log('⏭️ Ignoring stale podcast search results for "$query"');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Combine and deduplicate by name
|
||||
@@ -442,46 +465,55 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Combine all results
|
||||
var combinedResults = {
|
||||
...results,
|
||||
'radios': allRadios.values.toList(),
|
||||
'podcasts': allPodcasts.values.toList(),
|
||||
};
|
||||
// Race condition fix: Only update results if this is still the latest search
|
||||
if (!mounted) return;
|
||||
if (searchId != _currentSearchId) {
|
||||
_logger.log('⏭️ Ignoring stale search results for "$query"');
|
||||
return;
|
||||
}
|
||||
|
||||
// When "in library" mode is on, also filter by enabled providers
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
// Combine all results
|
||||
var combinedResults = {
|
||||
...results,
|
||||
'radios': allRadios.values.toList(),
|
||||
'podcasts': allPodcasts.values.toList(),
|
||||
};
|
||||
|
||||
setState(() {
|
||||
_searchResults = combinedResults;
|
||||
_isSearching = false;
|
||||
_hasSearched = true;
|
||||
_cachedListItems.clear(); // PERF: Clear cache on new results
|
||||
_cachedAvailableFilters = null;
|
||||
});
|
||||
// When "in library" mode is on, also filter by enabled providers
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
|
||||
// Save to search history if we got results
|
||||
final hasResults = results.values.any((list) => list.isNotEmpty);
|
||||
if (hasResults && DatabaseService.instance.isInitialized) {
|
||||
DatabaseService.instance.saveSearchQuery(query);
|
||||
_loadRecentSearches(); // Refresh the list
|
||||
}
|
||||
setState(() {
|
||||
_searchResults = combinedResults;
|
||||
_isSearching = false;
|
||||
_hasSearched = true;
|
||||
_hasPendingSearch = false;
|
||||
_cachedListItems.clear(); // PERF: Clear cache on new results
|
||||
_cachedAvailableFilters = null;
|
||||
});
|
||||
|
||||
// Keep keyboard open if user is still typing
|
||||
if (keepFocus && _focusNode.hasFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
// Save to search history if we got results
|
||||
final hasResults = results.values.any((list) => list.isNotEmpty);
|
||||
if (hasResults && DatabaseService.instance.isInitialized) {
|
||||
DatabaseService.instance.saveSearchQuery(query);
|
||||
_loadRecentSearches(); // Refresh the list
|
||||
}
|
||||
|
||||
// Keep keyboard open if user is still typing
|
||||
if (keepFocus && _focusNode.hasFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Search error: $e');
|
||||
if (mounted) {
|
||||
if (!mounted) return;
|
||||
// Race condition fix: Only update error state if this is still the latest search
|
||||
if (searchId == _currentSearchId) {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_hasSearched = true;
|
||||
_hasPendingSearch = false;
|
||||
_searchError = S.of(context)!.searchFailed;
|
||||
});
|
||||
if (keepFocus && _focusNode.hasFocus) {
|
||||
@@ -570,35 +602,47 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Library-only toggle with circle background
|
||||
Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_libraryOnly = !_libraryOnly;
|
||||
});
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Icon(
|
||||
Symbols.book_2,
|
||||
fill: _libraryOnly ? 1 : 0,
|
||||
size: 22,
|
||||
color: _libraryOnly
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final control = SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_libraryOnly = !_libraryOnly;
|
||||
});
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
customBorder: const CircleBorder(),
|
||||
child: Icon(
|
||||
Symbols.book_2,
|
||||
fill: _libraryOnly ? 1 : 0,
|
||||
size: 22,
|
||||
color: _libraryOnly
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Only use Tooltip if an Overlay ancestor exists
|
||||
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||
if (!hasOverlay) {
|
||||
return control;
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: S.of(context)!.libraryOnly,
|
||||
child: control,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -777,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,
|
||||
@@ -1156,6 +1201,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1198,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,
|
||||
@@ -1226,6 +1273,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1304,6 +1352,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
removeFocus();
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -1332,6 +1381,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1400,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);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1418,6 +1471,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1506,6 +1560,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
removeFocus();
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -1536,6 +1591,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1611,7 +1667,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
// Update adaptive colors before navigation
|
||||
removeFocus();
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -1638,6 +1694,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1686,7 +1743,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () => _playRadioStation(radio),
|
||||
onTap: () {
|
||||
removeFocus();
|
||||
_playRadioStation(radio);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1704,6 +1764,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1760,7 +1821,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
// Update adaptive colors before navigation
|
||||
removeFocus();
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../providers/locale_provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
@@ -8,6 +12,7 @@ import '../services/settings_service.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import 'debug_log_screen.dart';
|
||||
import 'login_screen.dart';
|
||||
@@ -20,8 +25,10 @@ class SettingsScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _scrollController = ScrollController();
|
||||
final _lastFmApiKeyController = TextEditingController();
|
||||
final _audioDbApiKeyController = TextEditingController();
|
||||
String _appVersion = '';
|
||||
// Main rows (default on)
|
||||
bool _showRecentAlbums = true;
|
||||
bool _showDiscoverArtists = true;
|
||||
@@ -46,11 +53,71 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showHints = true;
|
||||
// Display settings
|
||||
bool _showProviderIcons = true;
|
||||
// Discovery folders state
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
Map<String, bool> _discoveryRowEnabled = {};
|
||||
bool _isLoadingDiscoveryFolders = false;
|
||||
|
||||
/// All available static home screen rows (that can be toggled on/off)
|
||||
static const List<String> _allAvailableRows = [
|
||||
'recent-albums',
|
||||
'discover-artists',
|
||||
'discover-albums',
|
||||
'discovery-mixes',
|
||||
'continue-listening',
|
||||
'discover-audiobooks',
|
||||
'discover-series',
|
||||
'favorite-albums',
|
||||
'favorite-artists',
|
||||
'favorite-tracks',
|
||||
'favorite-playlists',
|
||||
'favorite-radio-stations',
|
||||
'favorite-podcasts',
|
||||
];
|
||||
|
||||
/// Filtered row order for the draggable list
|
||||
/// Shows all available rows (in home order + discovery rows), regardless of enabled state
|
||||
List<String> get _filteredRowOrder {
|
||||
final rows = <String>[];
|
||||
|
||||
// First, add all static rows in their current home order
|
||||
for (final rowId in _homeRowOrder) {
|
||||
if (rowId == 'discovery-mixes') continue;
|
||||
rows.add(rowId);
|
||||
}
|
||||
|
||||
// Then, add any static rows that aren't in home order yet (so users can enable them)
|
||||
for (final rowId in _allAvailableRows) {
|
||||
if (!_homeRowOrder.contains(rowId) && rowId != 'discovery-mixes') {
|
||||
rows.add(rowId);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, add any discovery rows from provider that aren't in home order yet
|
||||
for (final folder in _discoveryFolders) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
if (!_homeRowOrder.contains(discoveryRowId)) {
|
||||
rows.add(discoveryRowId);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
_loadAppVersion();
|
||||
}
|
||||
|
||||
Future<void> _loadAppVersion() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = '${info.version}+${info.buildNumber}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
@@ -93,6 +160,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load display settings
|
||||
final showProviderIcons = await SettingsService.getShowProviderIcons();
|
||||
|
||||
// Load discovery row preferences
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -112,20 +182,100 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showProviderIcons = showProviderIcons;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
});
|
||||
}
|
||||
|
||||
// Load discovery folders after initial state is set
|
||||
_loadDiscoveryFolders();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_lastFmApiKeyController.dispose();
|
||||
_audioDbApiKeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Load discovery folders from Music Assistant
|
||||
Future<void> _loadDiscoveryFolders() async {
|
||||
if (_discoveryFolders.isEmpty && mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = true;
|
||||
});
|
||||
}
|
||||
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
try {
|
||||
final folders = await provider.getDiscoveryFoldersWithCache();
|
||||
// Also reload discovery row preferences to ensure they're up to date
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryFolders = folders;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh discovery folders from Music Assistant
|
||||
Future<void> _refreshDiscoveryFolders() async {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
try {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = true;
|
||||
});
|
||||
await provider.refreshDiscoveryFolders();
|
||||
final folders = provider.getCachedDiscoveryFolders() ?? [];
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryFolders = folders;
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a specific discovery row on/off
|
||||
Future<void> _toggleDiscoveryRow(String itemId, bool enabled) async {
|
||||
setState(() {
|
||||
_discoveryRowEnabled[itemId] = enabled;
|
||||
});
|
||||
await SettingsService.setDiscoveryRowPreference(itemId, enabled);
|
||||
}
|
||||
|
||||
// Helper to get row display info
|
||||
Map<String, String> _getRowInfo(String rowId) {
|
||||
final s = S.of(context)!;
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
final folder = _discoveryFolders.firstWhere(
|
||||
(f) => f.itemId == itemId,
|
||||
orElse: () => RecommendationFolder(
|
||||
itemId: itemId,
|
||||
provider: 'unknown',
|
||||
name: rowId,
|
||||
items: [],
|
||||
),
|
||||
);
|
||||
return {'title': folder.name, 'subtitle': 'Discovery row from Music Assistant'};
|
||||
}
|
||||
switch (rowId) {
|
||||
case 'recent-albums':
|
||||
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
|
||||
@@ -133,6 +283,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
|
||||
case 'discover-albums':
|
||||
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
|
||||
case 'discovery-mixes':
|
||||
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription};
|
||||
case 'continue-listening':
|
||||
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
|
||||
case 'discover-audiobooks':
|
||||
@@ -158,6 +310,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
// Helper to get row enabled state
|
||||
bool _getRowEnabled(String rowId) {
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
return _discoveryRowEnabled[itemId] ?? false;
|
||||
}
|
||||
switch (rowId) {
|
||||
case 'recent-albums':
|
||||
return _showRecentAlbums;
|
||||
@@ -191,6 +348,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Helper to set row enabled state
|
||||
void _setRowEnabled(String rowId, bool value) {
|
||||
setState(() {
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
_discoveryRowEnabled[itemId] = value;
|
||||
SettingsService.setDiscoveryRowPreference(itemId, value);
|
||||
// Always add to home row order for position tracking
|
||||
if (!_homeRowOrder.contains(rowId)) {
|
||||
_homeRowOrder.add(rowId);
|
||||
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the setting
|
||||
switch (rowId) {
|
||||
case 'recent-albums':
|
||||
_showRecentAlbums = value;
|
||||
@@ -240,6 +411,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoritePodcasts = value;
|
||||
SettingsService.setShowFavoritePodcasts(value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// When enabling a row, add it to home row order if not already present
|
||||
if (value && !_homeRowOrder.contains(rowId)) {
|
||||
_homeRowOrder.add(rowId);
|
||||
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -274,7 +453,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () {
|
||||
// Settings is rendered conditionally in HomeScreen (not pushed as a route),
|
||||
// so Navigator.pop would pop the entire HomeScreen causing a black screen.
|
||||
// Instead, switch tab back to Home and restore the mini player.
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
navigationProvider.setSelectedIndex(0);
|
||||
},
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
title: Text(
|
||||
@@ -300,6 +485,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -845,7 +1031,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Reorderable home rows list
|
||||
// Reorderable home rows list (exclude discovery-mixes from list)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
@@ -856,24 +1042,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _homeRowOrder.length,
|
||||
itemCount: _filteredRowOrder.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
// Create a mutable copy of the filtered list for reordering
|
||||
final displayRows = List<String>.from(_filteredRowOrder);
|
||||
if (newIndex > oldIndex) newIndex--;
|
||||
final item = displayRows.removeAt(oldIndex);
|
||||
displayRows.insert(newIndex, item);
|
||||
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex--;
|
||||
final item = _homeRowOrder.removeAt(oldIndex);
|
||||
_homeRowOrder.insert(newIndex, item);
|
||||
// Update _homeRowOrder with the new order (all rows, including discovery)
|
||||
_homeRowOrder = displayRows;
|
||||
});
|
||||
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final rowId = _homeRowOrder[index];
|
||||
final rowId = _filteredRowOrder[index];
|
||||
final rowInfo = _getRowInfo(rowId);
|
||||
final isEnabled = _getRowEnabled(rowId);
|
||||
|
||||
return Container(
|
||||
key: ValueKey(rowId),
|
||||
decoration: BoxDecoration(
|
||||
border: index < _homeRowOrder.length - 1
|
||||
border: index < _filteredRowOrder.length - 1
|
||||
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
|
||||
: null,
|
||||
),
|
||||
@@ -1004,6 +1195,149 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// About & Support section
|
||||
Text(
|
||||
S.of(context)!.aboutAndSupport,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Support Development banner
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFEA4AAA).withOpacity(0.15),
|
||||
Color(0xFF1F6FEB).withOpacity(0.15),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Color(0xFFEA4AAA),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
S.of(context)!.supportDevelopment,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.ensembleIsUnofficial,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
// GitHub Sponsors button
|
||||
Expanded(
|
||||
child: _DonationButton(
|
||||
label: S.of(context)!.sponsorOnGitHub,
|
||||
color: Color(0xFF1F6FEB),
|
||||
iconWidget: Icon(MdiIcons.github, size: 20),
|
||||
onTap: () => _launchUrl('https://github.com/sponsors/CollotsSpot'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Ko-fi button
|
||||
Expanded(
|
||||
child: _DonationButton(
|
||||
icon: null,
|
||||
label: S.of(context)!.buyMeAKoFi,
|
||||
color: Color(0xFFFF5E5B),
|
||||
iconWidget: Icon(Icons.coffee_rounded, color: Colors.white),
|
||||
onTap: () => _launchUrl('https://ko-fi.com/collotsspot'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// About Ensemble card
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.aboutEnsemble,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.version,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_appVersion,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_LinkButton(
|
||||
icon: Icons.code_rounded,
|
||||
label: S.of(context)!.visitGitHubRepo,
|
||||
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_LinkButton(
|
||||
icon: Icons.bug_report_outlined,
|
||||
label: S.of(context)!.reportABug,
|
||||
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble/issues'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
@@ -1081,4 +1415,227 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the discovery section with individual row toggles
|
||||
Widget _buildDiscoverySection(ColorScheme colorScheme, TextTheme textTheme) {
|
||||
final s = S.of(context)!;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with title and refresh button
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.explore_outlined,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.discoveryRows,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoadingDiscoveryFolders)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: _refreshDiscoveryFolders,
|
||||
tooltip: s.refreshDiscoveryRows,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Discovery rows list
|
||||
if (_discoveryFolders.isEmpty && !_isLoadingDiscoveryFolders)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
s.noDiscoveryRows,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._discoveryFolders.map((folder) {
|
||||
final itemId = folder.itemId;
|
||||
final isEnabled = _discoveryRowEnabled[itemId] ?? false;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
folder.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
subtitle: folder.provider.isNotEmpty
|
||||
? Text(
|
||||
folder.provider,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 11,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
value: isEnabled,
|
||||
onChanged: (value) => _toggleDiscoveryRow(itemId, value),
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
dense: true,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom donation button widget
|
||||
class _DonationButton extends StatelessWidget {
|
||||
final String? icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final Widget? iconWidget;
|
||||
|
||||
const _DonationButton({
|
||||
this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
this.iconWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (iconWidget != null)
|
||||
iconWidget!
|
||||
else if (icon != null)
|
||||
Image.asset(
|
||||
icon!,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom link button widget
|
||||
class _LinkButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _LinkButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.open_in_new,
|
||||
size: 14,
|
||||
color: colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
@@ -10,6 +11,7 @@ import '../constants/timings.dart';
|
||||
class CacheService {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
bool _homeRowsLoaded = false;
|
||||
Future<void>? _homeRowsLoadFuture;
|
||||
|
||||
// Cache size limits to prevent unbounded memory growth
|
||||
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
|
||||
@@ -20,12 +22,14 @@ class CacheService {
|
||||
List<Album>? _cachedRecentAlbums;
|
||||
List<Artist>? _cachedDiscoverArtists;
|
||||
List<Album>? _cachedDiscoverAlbums;
|
||||
List<RecommendationFolder>? _cachedDiscoveryFolders;
|
||||
List<Audiobook>? _cachedInProgressAudiobooks;
|
||||
List<Audiobook>? _cachedDiscoverAudiobooks;
|
||||
List<AudiobookSeries>? _cachedDiscoverSeries;
|
||||
DateTime? _recentAlbumsLastFetched;
|
||||
DateTime? _discoverArtistsLastFetched;
|
||||
DateTime? _discoverAlbumsLastFetched;
|
||||
DateTime? _discoveryFoldersLastFetched;
|
||||
DateTime? _inProgressAudiobooksLastFetched;
|
||||
DateTime? _discoverAudiobooksLastFetched;
|
||||
DateTime? _discoverSeriesLastFetched;
|
||||
@@ -51,10 +55,21 @@ class CacheService {
|
||||
final Map<String, Track?> _playerTrackCache = {};
|
||||
final Map<String, DateTime> _playerTrackCacheTime = {};
|
||||
|
||||
// Last-known track cache (survives idle-out, for resume after Chromecast idle)
|
||||
final Map<String, Track> _lastKnownTrackCache = {};
|
||||
|
||||
// ============================================================================
|
||||
// HOME SCREEN ROW CACHING
|
||||
// ============================================================================
|
||||
|
||||
/// Ensure home rows are loaded from database before accessing cache
|
||||
/// This ensures the cache is populated on first access if not already loaded
|
||||
Future<void> ensureHomeRowsLoaded() async {
|
||||
if (!_homeRowsLoaded) {
|
||||
await loadHomeRowsFromDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if recent albums cache is valid
|
||||
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -69,6 +84,11 @@ class CacheService {
|
||||
|
||||
/// Set cached recent albums
|
||||
void setCachedRecentAlbums(List<Album> albums) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (albums.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty recent albums result');
|
||||
return;
|
||||
}
|
||||
_cachedRecentAlbums = albums;
|
||||
_recentAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} recent albums');
|
||||
@@ -90,6 +110,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover artists
|
||||
void setCachedDiscoverArtists(List<Artist> artists) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (artists.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover artists result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverArtists = artists;
|
||||
_discoverArtistsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${artists.length} discover artists');
|
||||
@@ -111,6 +136,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover albums
|
||||
void setCachedDiscoverAlbums(List<Album> albums) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (albums.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover albums result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverAlbums = albums;
|
||||
_discoverAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} discover albums');
|
||||
@@ -118,6 +148,32 @@ class CacheService {
|
||||
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if discovery folders cache is valid
|
||||
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final now = DateTime.now();
|
||||
return _cachedDiscoveryFolders != null &&
|
||||
_discoveryFoldersLastFetched != null &&
|
||||
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
|
||||
}
|
||||
|
||||
/// Get cached discovery folders
|
||||
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
|
||||
|
||||
/// Set cached discovery folders
|
||||
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (folders.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discovery folders result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoveryFolders = folders;
|
||||
_discoveryFoldersLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${folders.length} discovery folders');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if in-progress audiobooks cache is valid
|
||||
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -132,6 +188,11 @@ class CacheService {
|
||||
|
||||
/// Set cached in-progress audiobooks
|
||||
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
|
||||
// Don't cache empty results
|
||||
if (audiobooks.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty in-progress audiobooks result');
|
||||
return;
|
||||
}
|
||||
_cachedInProgressAudiobooks = audiobooks;
|
||||
_inProgressAudiobooksLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
|
||||
@@ -151,6 +212,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover audiobooks
|
||||
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
|
||||
// Don't cache empty results
|
||||
if (audiobooks.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover audiobooks result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverAudiobooks = audiobooks;
|
||||
_discoverAudiobooksLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
|
||||
@@ -170,6 +236,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover series
|
||||
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
|
||||
// Don't cache empty results
|
||||
if (series.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover series result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverSeries = series;
|
||||
_discoverSeriesLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${series.length} discover series');
|
||||
@@ -191,6 +262,7 @@ class CacheService {
|
||||
_recentAlbumsLastFetched = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_discoveryFoldersLastFetched = null;
|
||||
_inProgressAudiobooksLastFetched = null;
|
||||
_discoverAudiobooksLastFetched = null;
|
||||
_discoverSeriesLastFetched = null;
|
||||
@@ -250,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
|
||||
@@ -284,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
|
||||
@@ -415,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
|
||||
// ============================================================================
|
||||
@@ -431,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
|
||||
@@ -448,10 +544,22 @@ class CacheService {
|
||||
// ============================================================================
|
||||
|
||||
/// Load home row data from database for instant display on startup
|
||||
/// Returns the existing future if already loading, to prevent duplicate loads
|
||||
Future<void> loadHomeRowsFromDatabase() async {
|
||||
if (_homeRowsLoadFuture != null) {
|
||||
// Already loading, return the existing future
|
||||
return _homeRowsLoadFuture!;
|
||||
}
|
||||
if (_homeRowsLoaded) return;
|
||||
_homeRowsLoaded = true;
|
||||
|
||||
// Create the load future
|
||||
_homeRowsLoadFuture = _doLoadHomeRowsFromDatabase();
|
||||
await _homeRowsLoadFuture;
|
||||
_homeRowsLoadFuture = null; // Clear after completion
|
||||
}
|
||||
|
||||
/// Internal implementation of home row loading
|
||||
Future<void> _doLoadHomeRowsFromDatabase() async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
|
||||
@@ -499,8 +607,25 @@ class CacheService {
|
||||
_logger.log('⚠️ Failed to parse discover albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load discovery folders
|
||||
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
|
||||
if (foldersData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(foldersData.itemsJson) as List)
|
||||
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedDiscoveryFolders = items;
|
||||
_discoveryFoldersLastFetched = foldersData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} discovery folders from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse discovery folders: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading home rows from database: $e');
|
||||
} finally {
|
||||
_homeRowsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,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,175 @@
|
||||
import '../models/player.dart';
|
||||
import 'debug_logger.dart';
|
||||
|
||||
/// Manages volume state for group players where the MA API returns null volume.
|
||||
///
|
||||
/// Both pre-configured speaker groups (provider == 'player_group') and dynamic
|
||||
/// sync groups (ID typically starts with 'syncgroup_') don't have their own
|
||||
/// volume_level in the MA API. This manager computes an effective volume from
|
||||
/// group members and caches recently-set volumes to prevent slider snap-back.
|
||||
class GroupVolumeManager {
|
||||
final _logger = DebugLogger();
|
||||
|
||||
// Cache of last-set volume per group player ID.
|
||||
// Prevents slider snap-back between setVolume() and next player_updated event.
|
||||
final Map<String, int> _pendingVolumes = {};
|
||||
|
||||
// Timestamps for pending volumes - used to expire stale entries
|
||||
final Map<String, DateTime> _pendingTimestamps = {};
|
||||
|
||||
// How long to trust a pending volume before falling back to computed average.
|
||||
// For group players the API never returns a volume, so we keep the pending
|
||||
// value until the computed member average converges (within tolerance) or
|
||||
// until a generous timeout to avoid leaking state forever.
|
||||
static const Duration _pendingTimeout = Duration(seconds: 30);
|
||||
|
||||
// If the computed member average is within this many points of the pending
|
||||
// value, we consider the server to have confirmed it and clear the pending.
|
||||
static const int _confirmationTolerance = 3;
|
||||
|
||||
/// Check if a player is a group player that needs computed volume.
|
||||
///
|
||||
/// Detects both:
|
||||
/// - Pre-configured MA speaker groups (provider == 'player_group')
|
||||
/// - Dynamic sync groups (have group members but API returns null volume)
|
||||
bool isGroupPlayer(Player player) {
|
||||
// Pre-configured MA speaker groups
|
||||
if (player.provider == 'player_group') return true;
|
||||
// Dynamic sync groups: have multiple group members but MA returns null volume
|
||||
if (player.groupMembers != null &&
|
||||
player.groupMembers!.length > 1 &&
|
||||
player.volumeLevel == null) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get the effective volume for a player.
|
||||
///
|
||||
/// For non-group players: returns player.volumeLevel (unchanged behavior).
|
||||
/// For group players: returns cached pending volume if recent, otherwise
|
||||
/// computes average from group members.
|
||||
///
|
||||
/// [player] - The player to get volume for.
|
||||
/// [allPlayers] - All available players (needed to look up group members).
|
||||
///
|
||||
/// Returns volume as int 0-100, or null if no volume can be determined.
|
||||
int? getEffectiveVolumeLevel(Player player, List<Player> allPlayers) {
|
||||
if (!isGroupPlayer(player)) {
|
||||
return player.volumeLevel;
|
||||
}
|
||||
|
||||
// Check for a pending (just-set) volume
|
||||
final pending = _pendingVolumes[player.playerId];
|
||||
if (pending != null) {
|
||||
final timestamp = _pendingTimestamps[player.playerId];
|
||||
if (timestamp != null &&
|
||||
DateTime.now().difference(timestamp) < _pendingTimeout) {
|
||||
// Compute member average to see if server has converged
|
||||
final memberAvg = _computeGroupVolume(player, allPlayers);
|
||||
if (memberAvg != null && (memberAvg - pending).abs() <= _confirmationTolerance) {
|
||||
// Server confirmed — member average matches what we set
|
||||
_pendingVolumes.remove(player.playerId);
|
||||
_pendingTimestamps.remove(player.playerId);
|
||||
_logger.debug(
|
||||
'GroupVolume: Convergence confirmed for ${player.name}: '
|
||||
'pending=$pending, memberAvg=$memberAvg',
|
||||
context: 'GroupVolumeManager');
|
||||
return memberAvg;
|
||||
}
|
||||
// Not yet confirmed — keep showing the pending value
|
||||
_logger.debug(
|
||||
'GroupVolume: Pending protection active for ${player.name}: '
|
||||
'pending=$pending, memberAvg=$memberAvg, '
|
||||
'elapsed=${DateTime.now().difference(timestamp).inSeconds}s',
|
||||
context: 'GroupVolumeManager');
|
||||
return pending;
|
||||
}
|
||||
// Expired after generous timeout — remove it
|
||||
_logger.debug(
|
||||
'GroupVolume: Pending expired for ${player.name} after ${_pendingTimeout.inSeconds}s, '
|
||||
'falling back to computed average',
|
||||
context: 'GroupVolumeManager');
|
||||
_pendingVolumes.remove(player.playerId);
|
||||
_pendingTimestamps.remove(player.playerId);
|
||||
}
|
||||
|
||||
// Compute average from group members
|
||||
return _computeGroupVolume(player, allPlayers);
|
||||
}
|
||||
|
||||
/// Compute the average volume across all group members.
|
||||
/// Recursively resolves nested group members (MA sometimes makes the first
|
||||
/// member of a group also a group player, which returns null for volumeLevel).
|
||||
/// Returns null if no members have a valid volume.
|
||||
int? _computeGroupVolume(Player groupPlayer, List<Player> allPlayers,
|
||||
{int depth = 0}) {
|
||||
// Prevent infinite recursion from circular group references
|
||||
if (depth > 3) return null;
|
||||
|
||||
final memberIds = groupPlayer.groupMembers;
|
||||
if (memberIds == null || memberIds.isEmpty) {
|
||||
_logger.debug('GroupVolume: No group members for ${groupPlayer.name}',
|
||||
context: 'GroupVolumeManager');
|
||||
return null;
|
||||
}
|
||||
|
||||
int totalVolume = 0;
|
||||
int count = 0;
|
||||
|
||||
for (final memberId in memberIds) {
|
||||
// Skip self-references (groupMembers can include the group's own ID)
|
||||
if (memberId == groupPlayer.playerId) continue;
|
||||
|
||||
final member =
|
||||
allPlayers.where((p) => p.playerId == memberId).firstOrNull;
|
||||
if (member == null) continue;
|
||||
|
||||
if (member.volumeLevel != null) {
|
||||
totalVolume += member.volumeLevel!;
|
||||
count++;
|
||||
} else if (isGroupPlayer(member)) {
|
||||
// Nested group player — MA returns null volumeLevel for these.
|
||||
// Recursively compute from the nested group's own members.
|
||||
final subVolume =
|
||||
_computeGroupVolume(member, allPlayers, depth: depth + 1);
|
||||
if (subVolume != null) {
|
||||
totalVolume += subVolume;
|
||||
count++;
|
||||
_logger.debug(
|
||||
'GroupVolume: Resolved nested group ${member.name} → volume $subVolume',
|
||||
context: 'GroupVolumeManager');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0) {
|
||||
_logger.debug(
|
||||
'GroupVolume: No members with volume for ${groupPlayer.name} '
|
||||
'(${memberIds.length} members checked, depth=$depth)',
|
||||
context: 'GroupVolumeManager');
|
||||
return null;
|
||||
}
|
||||
|
||||
final avg = (totalVolume / count).round();
|
||||
_logger.debug(
|
||||
'GroupVolume: ${groupPlayer.name} average=$avg from $count members '
|
||||
'(total=$totalVolume, depth=$depth)',
|
||||
context: 'GroupVolumeManager');
|
||||
return avg;
|
||||
}
|
||||
|
||||
/// Record that a volume was just set on a group player.
|
||||
/// This prevents the slider from snapping back to 0 before the
|
||||
/// MA server processes the volume change on all members.
|
||||
void onVolumeSet(String playerId, int volumeLevel) {
|
||||
_pendingVolumes[playerId] = volumeLevel;
|
||||
_pendingTimestamps[playerId] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Clear all cached state (e.g., on disconnect)
|
||||
void clear() {
|
||||
_pendingVolumes.clear();
|
||||
_pendingTimestamps.clear();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import 'debug_logger.dart';
|
||||
///
|
||||
/// For MA players: intercept buttons and send volume commands via API
|
||||
/// For builtin player: let native volume work (don't intercept)
|
||||
///
|
||||
/// Also provides a volume observer for lockscreen volume changes.
|
||||
/// When the app is in the background/lockscreen, dispatchKeyEvent doesn't fire,
|
||||
/// so the observer watches system STREAM_MUSIC volume changes and sends
|
||||
/// absolute volume values to Flutter for routing to the MA player.
|
||||
class HardwareVolumeService {
|
||||
static final HardwareVolumeService _instance = HardwareVolumeService._internal();
|
||||
factory HardwareVolumeService() => _instance;
|
||||
@@ -17,16 +22,25 @@ class HardwareVolumeService {
|
||||
|
||||
final _volumeUpController = StreamController<void>.broadcast();
|
||||
final _volumeDownController = StreamController<void>.broadcast();
|
||||
final _absoluteVolumeController = StreamController<int>.broadcast();
|
||||
|
||||
Stream<void> get onVolumeUp => _volumeUpController.stream;
|
||||
Stream<void> get onVolumeDown => _volumeDownController.stream;
|
||||
|
||||
/// Stream of absolute volume values (0-100) from lockscreen hardware buttons.
|
||||
/// Fires when the system STREAM_MUSIC volume changes while the volume
|
||||
/// observer is active (remote/group player selected, app in background).
|
||||
Stream<int> get onAbsoluteVolumeChange => _absoluteVolumeController.stream;
|
||||
|
||||
bool _isListening = false;
|
||||
bool get isListening => _isListening;
|
||||
|
||||
bool _isIntercepting = false;
|
||||
bool get isIntercepting => _isIntercepting;
|
||||
|
||||
bool _isObservingVolume = false;
|
||||
bool get isObservingVolume => _isObservingVolume;
|
||||
|
||||
/// Initialize the service and start listening for volume button events
|
||||
Future<void> init() async {
|
||||
if (_isListening) return;
|
||||
@@ -39,6 +53,11 @@ class HardwareVolumeService {
|
||||
case 'volumeDown':
|
||||
_volumeDownController.add(null);
|
||||
break;
|
||||
case 'absoluteVolumeChange':
|
||||
// Volume changed from lockscreen/background via ContentObserver
|
||||
final volume = call.arguments as int? ?? 0;
|
||||
_absoluteVolumeController.add(volume);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -70,8 +89,53 @@ class HardwareVolumeService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the volume observer for lockscreen/background volume changes.
|
||||
/// When active, system STREAM_MUSIC volume changes are detected and sent
|
||||
/// to Flutter via [onAbsoluteVolumeChange]. Optionally sets the system
|
||||
/// volume to [initialVolume] (0-100) to match the MA player's current volume.
|
||||
Future<void> startVolumeObserver({int? initialVolume}) async {
|
||||
if (_isObservingVolume) return;
|
||||
|
||||
try {
|
||||
await _channel.invokeMethod('startVolumeObserver', {
|
||||
if (initialVolume != null) 'initialVolume': initialVolume,
|
||||
});
|
||||
_isObservingVolume = true;
|
||||
_logger.info('Volume observer started', context: 'VolumeService');
|
||||
} catch (e) {
|
||||
_logger.error('Failed to start volume observer', context: 'VolumeService', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the volume observer.
|
||||
Future<void> stopVolumeObserver() async {
|
||||
if (!_isObservingVolume) return;
|
||||
|
||||
try {
|
||||
await _channel.invokeMethod('stopVolumeObserver');
|
||||
_isObservingVolume = false;
|
||||
_logger.info('Volume observer stopped', context: 'VolumeService');
|
||||
} catch (e) {
|
||||
_logger.error('Failed to stop volume observer', context: 'VolumeService', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync the system volume to match the MA player volume (0-100).
|
||||
/// Used to keep the system volume HUD in sync with the MA player.
|
||||
Future<void> syncSystemVolume(int maVolume) async {
|
||||
try {
|
||||
await _channel.invokeMethod('syncSystemVolume', {'volume': maVolume});
|
||||
} catch (e) {
|
||||
_logger.error('Failed to sync system volume', context: 'VolumeService', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop listening for volume button events
|
||||
Future<void> dispose() async {
|
||||
if (_isObservingVolume) {
|
||||
await stopVolumeObserver();
|
||||
}
|
||||
|
||||
if (!_isListening) return;
|
||||
|
||||
try {
|
||||
@@ -86,5 +150,6 @@ class HardwareVolumeService {
|
||||
// Close StreamControllers to prevent memory leaks
|
||||
await _volumeUpController.close();
|
||||
await _volumeDownController.close();
|
||||
await _absoluteVolumeController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'device_id_service.dart';
|
||||
@@ -214,6 +215,7 @@ class MusicAssistantAPI {
|
||||
},
|
||||
onDone: () {
|
||||
_logger.log('WebSocket connection closed');
|
||||
_isAuthenticated = false; // Reset auth state on disconnect
|
||||
_updateConnectionState(MAConnectionState.disconnected);
|
||||
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||
_connectionCompleter!.completeError(Exception('Connection closed'));
|
||||
@@ -249,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')) {
|
||||
@@ -1194,6 +1196,28 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ RECOMMENDATIONS ============
|
||||
|
||||
/// Get recommendation folders (mixes, recommendations, etc.)
|
||||
Future<List<RecommendationFolder>> getRecommendations() async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/recommendations',
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(RecommendationFolder.fromJson)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting recommendations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the audiobookshelf provider instance ID
|
||||
Future<String?> getAudiobookshelfProviderId() async {
|
||||
try {
|
||||
@@ -1442,9 +1466,10 @@ class MusicAssistantAPI {
|
||||
final futures = batch.map((uri) => _sendCommand(
|
||||
'music/item_by_uri',
|
||||
args: {'uri': uri},
|
||||
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
|
||||
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null})
|
||||
.catchError((_) => <String, dynamic>{'result': null}));
|
||||
|
||||
final results = await Future.wait(futures, eagerError: false);
|
||||
final results = await Future.wait(futures);
|
||||
|
||||
for (final trackResponse in results) {
|
||||
if (albumUris.length >= limit) break;
|
||||
@@ -1481,9 +1506,10 @@ class MusicAssistantAPI {
|
||||
final futures = batch.map((uri) => _sendCommand(
|
||||
'music/item_by_uri',
|
||||
args: {'uri': uri},
|
||||
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
|
||||
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null})
|
||||
.catchError((_) => <String, dynamic>{'result': null}));
|
||||
|
||||
final results = await Future.wait(futures, eagerError: false);
|
||||
final results = await Future.wait(futures);
|
||||
|
||||
for (final albumResponse in results) {
|
||||
try {
|
||||
@@ -1595,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
|
||||
@@ -1685,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
|
||||
@@ -1970,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?;
|
||||
@@ -2006,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');
|
||||
@@ -657,7 +675,10 @@ class SendspinService {
|
||||
void _updateState(SendspinConnectionState newState) {
|
||||
if (_state != newState) {
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
// Don't try to emit if disposed or controller is closed
|
||||
if (!_isDisposed && !_stateController.isClosed) {
|
||||
_stateController.add(newState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ class SettingsService {
|
||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||
static const String _keyShowDiscoveryFolders = 'show_discovery_folders';
|
||||
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
|
||||
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
|
||||
static const String _keyShowDiscoverSeries = 'show_discover_series';
|
||||
@@ -106,20 +107,11 @@ class SettingsService {
|
||||
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
|
||||
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
|
||||
|
||||
// Default row order
|
||||
// Default row order (only these 3 rows enabled by default on first launch)
|
||||
static const List<String> defaultHomeRowOrder = [
|
||||
'recent-albums',
|
||||
'discover-artists',
|
||||
'discover-albums',
|
||||
'continue-listening',
|
||||
'discover-audiobooks',
|
||||
'discover-series',
|
||||
'favorite-albums',
|
||||
'favorite-artists',
|
||||
'favorite-tracks',
|
||||
'favorite-playlists',
|
||||
'favorite-radio-stations',
|
||||
'favorite-podcasts',
|
||||
];
|
||||
|
||||
// View Mode Settings
|
||||
@@ -177,6 +169,9 @@ class SettingsService {
|
||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||
|
||||
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
|
||||
static const String _keyDiscoveryRowPreferences = 'discovery_row_preferences';
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
@@ -391,6 +386,10 @@ class SettingsService {
|
||||
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
|
||||
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
|
||||
|
||||
// Home Screen Discovery Folders (default off - provider mixes like Tidal)
|
||||
static Future<bool> getShowDiscoveryFolders() => _getBool(_keyShowDiscoveryFolders, defaultValue: false);
|
||||
static Future<void> setShowDiscoveryFolders(bool show) => _setBool(_keyShowDiscoveryFolders, show);
|
||||
|
||||
// Home Screen Audiobook Rows (default off - optional)
|
||||
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
|
||||
static Future<void> setShowContinueListeningAudiobooks(bool show) => _setBool(_keyShowContinueListeningAudiobooks, show);
|
||||
@@ -939,7 +938,7 @@ class SettingsService {
|
||||
/// for fine-grained control (10x more precise)
|
||||
static Future<bool> getVolumePrecisionMode() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
|
||||
return prefs.getBool(_keyVolumePrecisionMode) ?? false;
|
||||
}
|
||||
|
||||
/// Set whether volume precision mode is enabled
|
||||
@@ -976,4 +975,46 @@ class SettingsService {
|
||||
cache[podcastId] = url;
|
||||
await setPodcastCoverCache(cache);
|
||||
}
|
||||
|
||||
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
|
||||
|
||||
/// Get all discovery row preferences as a map (itemId -> enabled)
|
||||
/// Defaults to all enabled (true) if not set
|
||||
static Future<Map<String, bool>> getDiscoveryRowPreferences() async {
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyDiscoveryRowPreferences);
|
||||
if (json == null) return {}; // Empty map = all enabled by default
|
||||
try {
|
||||
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||
return decoded.map((key, value) => MapEntry(key, value as bool));
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Set all discovery row preferences at once
|
||||
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async {
|
||||
final sharedPrefs = await _getPrefs();
|
||||
await sharedPrefs.setString(_keyDiscoveryRowPreferences, jsonEncode(prefs));
|
||||
}
|
||||
|
||||
/// Get whether a specific discovery row is enabled
|
||||
/// Returns false if not explicitly set (default off)
|
||||
static Future<bool> getDiscoveryRowPreference(String itemId) async {
|
||||
final allPrefs = await getDiscoveryRowPreferences();
|
||||
return allPrefs[itemId] ?? false; // Default to disabled
|
||||
}
|
||||
|
||||
/// Set whether a specific discovery row is enabled
|
||||
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async {
|
||||
final allPrefs = await getDiscoveryRowPreferences();
|
||||
allPrefs[itemId] = enabled;
|
||||
await setDiscoveryRowPreferences(allPrefs);
|
||||
}
|
||||
|
||||
/// Clear all discovery row preferences (reverts to all enabled)
|
||||
static Future<void> clearDiscoveryRowPreferences() async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyDiscoveryRowPreferences);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class SyncService with ChangeNotifier {
|
||||
List<Artist> _cachedArtists = [];
|
||||
List<Audiobook> _cachedAudiobooks = [];
|
||||
List<Playlist> _cachedPlaylists = [];
|
||||
List<MediaItem> _cachedPodcasts = [];
|
||||
|
||||
// Source provider tracking for client-side filtering
|
||||
// Maps itemId -> list of provider instance IDs that provided the item
|
||||
@@ -50,7 +51,6 @@ class SyncService with ChangeNotifier {
|
||||
Map<String, List<String>> _artistSourceProviders = {};
|
||||
Map<String, List<String>> _audiobookSourceProviders = {};
|
||||
Map<String, List<String>> _playlistSourceProviders = {};
|
||||
|
||||
// Getters
|
||||
SyncStatus get status => _status;
|
||||
String? get lastError => _lastError;
|
||||
@@ -60,15 +60,16 @@ class SyncService with ChangeNotifier {
|
||||
List<Artist> get cachedArtists => _cachedArtists;
|
||||
List<Audiobook> get cachedAudiobooks => _cachedAudiobooks;
|
||||
List<Playlist> get cachedPlaylists => _cachedPlaylists;
|
||||
List<MediaItem> get cachedPodcasts => _cachedPodcasts;
|
||||
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty ||
|
||||
_cachedPodcasts.isNotEmpty;
|
||||
|
||||
// Source provider getters for client-side filtering
|
||||
Map<String, List<String>> get albumSourceProviders => _albumSourceProviders;
|
||||
Map<String, List<String>> get artistSourceProviders => _artistSourceProviders;
|
||||
Map<String, List<String>> get audiobookSourceProviders => _audiobookSourceProviders;
|
||||
Map<String, List<String>> get playlistSourceProviders => _playlistSourceProviders;
|
||||
|
||||
/// Load library data from database cache (instant)
|
||||
/// Call this on app startup for immediate data
|
||||
/// Also loads source provider info for client-side filtering
|
||||
@@ -145,8 +146,20 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Load podcasts from cache (no source provider tracking - API doesn't support filtering)
|
||||
final podcastData = await _db.getCachedItems('podcast');
|
||||
_cachedPodcasts = [];
|
||||
for (final data in podcastData) {
|
||||
try {
|
||||
_cachedPodcasts.add(MediaItem.fromJson(data));
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached podcast: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
|
||||
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
|
||||
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists, '
|
||||
'${_cachedPodcasts.length} podcasts from cache');
|
||||
_logger.log('📦 Source providers: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists tracked');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -173,13 +186,16 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
|
||||
// Check if sync is needed (default: every 5 minutes)
|
||||
if (!force) {
|
||||
// Always sync if cache is empty (no albums AND no artists loaded)
|
||||
final cacheEmpty = _cachedAlbums.isEmpty && _cachedArtists.isEmpty;
|
||||
if (!force && !cacheEmpty) {
|
||||
final albumsNeedSync = await _db.needsSync('albums', maxAge: const Duration(minutes: 5));
|
||||
final artistsNeedSync = await _db.needsSync('artists', maxAge: const Duration(minutes: 5));
|
||||
final audiobooksNeedSync = await _db.needsSync('audiobooks', maxAge: const Duration(minutes: 5));
|
||||
final playlistsNeedSync = await _db.needsSync('playlists', maxAge: const Duration(minutes: 5));
|
||||
final podcastsNeedSync = await _db.needsSync('podcasts', maxAge: const Duration(minutes: 5));
|
||||
|
||||
if (!albumsNeedSync && !artistsNeedSync && !audiobooksNeedSync && !playlistsNeedSync) {
|
||||
if (!albumsNeedSync && !artistsNeedSync && !audiobooksNeedSync && !playlistsNeedSync && !podcastsNeedSync) {
|
||||
_logger.log('✅ Cache is fresh, skipping sync');
|
||||
return;
|
||||
}
|
||||
@@ -205,6 +221,7 @@ class SyncService with ChangeNotifier {
|
||||
await _db.clearCacheForType('artist');
|
||||
await _db.clearCacheForType('audiobook');
|
||||
await _db.clearCacheForType('playlist');
|
||||
await _db.clearCacheForType('podcast');
|
||||
}
|
||||
|
||||
// Build NEW source tracking maps - don't modify existing until sync is complete
|
||||
@@ -249,6 +266,9 @@ class SyncService with ChangeNotifier {
|
||||
_logger.log(' 📡 Syncing provider: $providerId');
|
||||
|
||||
// Fetch from this specific provider
|
||||
// Note: Tracks are NOT fetched during background sync to avoid
|
||||
// overwhelming provider rate limits (especially Spotify).
|
||||
// Tracks are loaded on-demand when the user visits the tracks tab.
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
|
||||
@@ -261,7 +281,7 @@ class SyncService with ChangeNotifier {
|
||||
final audiobooks = results[2] as List<Audiobook>;
|
||||
final playlists = results[3] as List<Playlist>;
|
||||
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks, ${playlists.length} playlists from $providerId');
|
||||
|
||||
// Add to maps and track source provider in NEW tracking maps
|
||||
for (final album in albums) {
|
||||
@@ -323,20 +343,31 @@ class SyncService with ChangeNotifier {
|
||||
final audiobooks = audiobookMap.values.toList();
|
||||
final playlists = playlistMap.values.toList();
|
||||
|
||||
// Fetch podcasts separately (API doesn't support providerInstanceIds)
|
||||
List<MediaItem> podcasts = [];
|
||||
try {
|
||||
podcasts = await api.getPodcasts(limit: 100);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to fetch podcasts: $e');
|
||||
}
|
||||
|
||||
_logger.log('📥 Total: ${albums.length} albums, ${artists.length} artists, '
|
||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists');
|
||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists, '
|
||||
'${podcasts.length} podcasts');
|
||||
|
||||
// Save to database cache with source provider info (using NEW tracking maps)
|
||||
await _saveAlbumsToCache(albums, newAlbumSourceProviders);
|
||||
await _saveArtistsToCache(artists, newArtistSourceProviders);
|
||||
await _saveAudiobooksToCache(audiobooks, newAudiobookSourceProviders);
|
||||
await _savePlaylistsToCache(playlists, newPlaylistSourceProviders);
|
||||
await _savePodcastsToCache(podcasts);
|
||||
|
||||
// Update sync metadata
|
||||
await _db.updateSyncMetadata('albums', albums.length);
|
||||
await _db.updateSyncMetadata('artists', artists.length);
|
||||
await _db.updateSyncMetadata('audiobooks', audiobooks.length);
|
||||
await _db.updateSyncMetadata('playlists', playlists.length);
|
||||
await _db.updateSyncMetadata('podcasts', podcasts.length);
|
||||
|
||||
// Update in-memory cache
|
||||
if (isPartialSync) {
|
||||
@@ -370,6 +401,9 @@ class SyncService with ChangeNotifier {
|
||||
_cachedAudiobooks = audiobooks;
|
||||
_cachedPlaylists = playlists;
|
||||
}
|
||||
// Podcasts always full replace (no per-provider tracking)
|
||||
_cachedPodcasts = podcasts;
|
||||
|
||||
// Atomically swap in the new source tracking maps
|
||||
// This ensures filtering always sees complete data, never partial
|
||||
_albumSourceProviders = newAlbumSourceProviders;
|
||||
@@ -472,6 +506,24 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save podcasts to database cache (batched, no source provider tracking)
|
||||
Future<void> _savePodcastsToCache(List<MediaItem> podcasts) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final podcast in podcasts) {
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'podcast',
|
||||
itemId: podcast.itemId,
|
||||
data: jsonEncode(podcast.toJson()),
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${podcasts.length} podcasts to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache podcasts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Force a fresh sync (for pull-to-refresh)
|
||||
/// [providerInstanceIds] - optional list of provider IDs to filter by (null = all providers)
|
||||
Future<void> forceSync(MusicAssistantAPI api, {List<String>? providerInstanceIds}) async {
|
||||
@@ -487,6 +539,7 @@ class SyncService with ChangeNotifier {
|
||||
_cachedArtists = [];
|
||||
_cachedAudiobooks = [];
|
||||
_cachedPlaylists = [];
|
||||
_cachedPodcasts = [];
|
||||
_albumSourceProviders = {};
|
||||
_artistSourceProviders = {};
|
||||
_audiobookSourceProviders = {};
|
||||
@@ -572,9 +625,13 @@ class SyncService with ChangeNotifier {
|
||||
/// Get playlists (from cache or empty if not loaded)
|
||||
List<Playlist> getPlaylists() => _cachedPlaylists;
|
||||
|
||||
/// Get podcasts (from cache or empty if not loaded)
|
||||
List<MediaItem> getPodcasts() => _cachedPodcasts;
|
||||
|
||||
/// Check if we have data available (from cache or sync)
|
||||
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty ||
|
||||
_cachedPodcasts.isNotEmpty;
|
||||
|
||||
/// Update cached albums with sorted data from provider
|
||||
/// Used when sort order changes - preserves source provider tracking
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../services/image_prefetch_service.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/album_details_screen.dart';
|
||||
@@ -59,15 +60,20 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.album.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
// Defer status initialization to after the build frame completes.
|
||||
// setLibraryStatus() calls notifyListeners() synchronously, which triggers
|
||||
// setState() in listening widgets — illegal during the build phase.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.album.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
});
|
||||
_initFallbackImage();
|
||||
}
|
||||
|
||||
@@ -372,6 +378,7 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
cacheManager: AlbumImageCacheManager(),
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../services/image_prefetch_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
@@ -14,6 +15,8 @@ class AlbumRow extends StatefulWidget {
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<Album>? Function()? getCachedAlbums;
|
||||
/// Optional: signal to trigger in-place reload without destroying the widget
|
||||
final ValueNotifier<int>? refreshSignal;
|
||||
|
||||
const AlbumRow({
|
||||
super.key,
|
||||
@@ -22,6 +25,7 @@ class AlbumRow extends StatefulWidget {
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedAlbums,
|
||||
this.refreshSignal,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -46,6 +50,19 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadAlbums();
|
||||
widget.refreshSignal?.addListener(_onRefreshSignal);
|
||||
}
|
||||
|
||||
void _onRefreshSignal() {
|
||||
if (!mounted) return;
|
||||
_hasLoaded = false;
|
||||
_loadAlbums();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.refreshSignal?.removeListener(_onRefreshSignal);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadAlbums() async {
|
||||
@@ -85,7 +102,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
if (imageUrl != null) {
|
||||
// Use CachedNetworkImageProvider to warm the cache
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
CachedNetworkImageProvider(imageUrl, cacheManager: AlbumImageCacheManager()),
|
||||
context,
|
||||
).catchError((_) {
|
||||
// Silently ignore precache errors
|
||||
|
||||
@@ -59,15 +59,20 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize status in centralized service from widget data
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
// Defer status initialization to after the build frame completes.
|
||||
// setLibraryStatus() calls notifyListeners() synchronously, which triggers
|
||||
// setState() in listening widgets — illegal during the build phase.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final service = LibraryStatusService.instance;
|
||||
final key = libraryItemKey;
|
||||
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
|
||||
service.setLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
||||
service.setFavoriteStatus(key, true);
|
||||
}
|
||||
});
|
||||
// Fetch fallback image once in initState, not during build
|
||||
_initFallbackImage();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class ArtistRow extends StatefulWidget {
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<Artist>? Function()? getCachedArtists;
|
||||
/// Optional: signal to trigger in-place reload without destroying the widget
|
||||
final ValueNotifier<int>? refreshSignal;
|
||||
|
||||
const ArtistRow({
|
||||
super.key,
|
||||
@@ -22,6 +24,7 @@ class ArtistRow extends StatefulWidget {
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedArtists,
|
||||
this.refreshSignal,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -46,6 +49,19 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadArtists();
|
||||
widget.refreshSignal?.addListener(_onRefreshSignal);
|
||||
}
|
||||
|
||||
void _onRefreshSignal() {
|
||||
if (!mounted) return;
|
||||
_hasLoaded = false;
|
||||
_loadArtists();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.refreshSignal?.removeListener(_onRefreshSignal);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadArtists() async {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import 'playlist_card.dart';
|
||||
import 'radio_station_card.dart';
|
||||
|
||||
class DiscoveryRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<MediaItem>> Function() loadItems;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<MediaItem>? Function()? getCachedItems;
|
||||
|
||||
const DiscoveryRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadItems,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedItems,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DiscoveryRow> createState() => _DiscoveryRowState();
|
||||
}
|
||||
|
||||
class _DiscoveryRowState extends State<DiscoveryRow> with AutomaticKeepAliveClientMixin {
|
||||
List<MediaItem> _items = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedItems?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_items = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadItems();
|
||||
}
|
||||
|
||||
Future<void> _loadItems() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||
try {
|
||||
final freshItems = await widget.loadItems();
|
||||
if (mounted && freshItems.isNotEmpty) {
|
||||
setState(() {
|
||||
_items = freshItems;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precacheImages(freshItems);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-cache images so hero animations are smooth on first tap
|
||||
void _precacheImages(List<MediaItem> items) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Only precache first ~10 visible items to avoid excessive network/memory use
|
||||
final itemsToCache = items.take(10);
|
||||
|
||||
for (final item in itemsToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(item, size: 256);
|
||||
if (imageUrl != null) {
|
||||
// Use CachedNetworkImageProvider to warm the cache
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) {
|
||||
// Silently ignore precache errors
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_items.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
S.of(context)!.noAlbumsFound,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Card layout: square artwork + text below
|
||||
// Text area: 8px gap + ~18px title + ~18px subtitle = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
clipBehavior: Clip.none,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _items.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
final key = ValueKey(item.uri ?? item.itemId);
|
||||
|
||||
Widget card;
|
||||
if (item is Playlist) {
|
||||
card = PlaylistCard(
|
||||
playlist: item,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
);
|
||||
} else if (item.mediaType == MediaType.radio) {
|
||||
card = RadioStationCard(
|
||||
radioStation: item,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
);
|
||||
} else {
|
||||
// Fallback for other media types
|
||||
card = _buildFallbackCard(item, cardWidth);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Container(
|
||||
key: key,
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: card,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFallbackCard(MediaItem item, double width) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Container(
|
||||
width: width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
_getIconForMediaType(item.mediaType),
|
||||
size: 48,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.name,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getIconForMediaType(MediaType type) {
|
||||
switch (type) {
|
||||
case MediaType.album:
|
||||
return Icons.album;
|
||||
case MediaType.artist:
|
||||
return Icons.person;
|
||||
case MediaType.track:
|
||||
return Icons.music_note;
|
||||
case MediaType.playlist:
|
||||
return Icons.playlist_play;
|
||||
case MediaType.radio:
|
||||
return Icons.radio;
|
||||
case MediaType.audiobook:
|
||||
return Icons.book;
|
||||
case MediaType.podcast:
|
||||
return Icons.podcasts;
|
||||
case MediaType.podcastEpisode:
|
||||
return Icons.podcasts;
|
||||
default:
|
||||
return Icons.audiotrack;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('DiscoveryRow:${widget.title}');
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Total row height includes title + content
|
||||
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||
final contentHeight = totalHeight - titleHeight;
|
||||
|
||||
final result = RepaintBoundary(
|
||||
child: SizedBox(
|
||||
height: totalHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildContent(contentHeight, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
_logger.endBuild('DiscoveryRow:${widget.title}');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||
class _StretchScrollBehavior extends ScrollBehavior {
|
||||
const _StretchScrollBehavior();
|
||||
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return StretchingOverscrollIndicator(
|
||||
axisDirection: details.direction,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
static const double _collapsedBorderRadius = 16.0;
|
||||
static double get _collapsedArtSize => MiniPlayerLayout.artSize;
|
||||
static const double _bottomNavHeight = 56.0;
|
||||
|
||||
// Animation for hiding bottom nav offset on detail screens
|
||||
late AnimationController _detailScreenController;
|
||||
bool _lastIsOnDetailScreen = false;
|
||||
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
|
||||
|
||||
// Pastel yellow for grouped players (matches PlayerCard.groupBorderColor)
|
||||
@@ -318,6 +322,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Track lyrics scroll position for swipe-to-close (only allowed at top)
|
||||
_lyricsScrollController.addListener(_onLyricsScroll);
|
||||
|
||||
// Animation for transitioning mini player when entering/leaving detail screens
|
||||
_detailScreenController = AnimationController(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
vsync: this,
|
||||
);
|
||||
_detailScreenController.addListener(_onDetailScreenAnimation);
|
||||
|
||||
// Slide animation for device switching - used for snap/spring animations
|
||||
_slideController = AnimationController(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
@@ -356,6 +367,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_loadVolumePrecisionModeSetting();
|
||||
}
|
||||
|
||||
void _onDetailScreenAnimation() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _loadVolumePrecisionModeSetting() async {
|
||||
final enabled = await SettingsService.getVolumePrecisionMode();
|
||||
if (mounted) {
|
||||
@@ -415,6 +430,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// (Controllers remove listeners on dispose, but explicit removal is clearer)
|
||||
_controller.removeListener(_notifyExpansionProgress);
|
||||
_controller.removeListener(_recordAnimationFrame);
|
||||
_detailScreenController.removeListener(_onDetailScreenAnimation);
|
||||
_lyricsScrollController.removeListener(_onLyricsScroll);
|
||||
|
||||
_controller.dispose();
|
||||
@@ -422,6 +438,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_sleepTimerPanelController.dispose();
|
||||
_lyricsPanelController.dispose();
|
||||
_slideController.dispose();
|
||||
_detailScreenController.dispose();
|
||||
_slideOffsetNotifier.dispose();
|
||||
_positionSubscription?.cancel();
|
||||
_queueItemsSubscription?.cancel();
|
||||
@@ -640,7 +657,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_logger.log('📊 Subscribing to positionStream for progress bar');
|
||||
_positionSubscription = maProvider.positionTracker.positionStream.listen((position) {
|
||||
if (!mounted) return;
|
||||
_logger.log('📊 Progress received position: ${position.inSeconds}s');
|
||||
_logger.debug('📊 Progress received position: ${position.inSeconds}s');
|
||||
_progressNotifier.value = position.inSeconds;
|
||||
});
|
||||
}
|
||||
@@ -1794,9 +1811,23 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
Widget build(BuildContext context) {
|
||||
// PERF: Use select() to only rebuild when adaptive theme setting changes
|
||||
final adaptiveTheme = context.select<ThemeProvider, bool>((p) => p.adaptiveTheme);
|
||||
// Watch detail screen state to animate mini player position
|
||||
final isOnDetailScreen = context.select<ThemeProvider, bool>((p) => p.isOnDetailScreen);
|
||||
// Get full themeProvider for methods that need adaptive color schemes
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
|
||||
// Animate bottom nav offset when entering/leaving detail screens
|
||||
if (isOnDetailScreen != _lastIsOnDetailScreen) {
|
||||
_lastIsOnDetailScreen = isOnDetailScreen;
|
||||
if (isOnDetailScreen) {
|
||||
_detailScreenController.duration = const Duration(milliseconds: 100);
|
||||
_detailScreenController.forward();
|
||||
} else {
|
||||
_detailScreenController.duration = const Duration(milliseconds: 80);
|
||||
_detailScreenController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
@@ -1925,7 +1956,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
? adaptiveScheme.onPrimaryContainer
|
||||
: colorScheme.onPrimaryContainer;
|
||||
|
||||
final bottomNavSpace = _bottomNavHeight + bottomPadding;
|
||||
final effectiveNavHeight = _bottomNavHeight * (1.0 - _detailScreenController.value);
|
||||
final bottomNavSpace = effectiveNavHeight + bottomPadding;
|
||||
final bottomOffset = bottomNavSpace + _collapsedMargin;
|
||||
final width = screenSize.width - (_collapsedMargin * 2);
|
||||
|
||||
@@ -2074,9 +2106,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final textAlignment = Alignment.lerp(Alignment.centerLeft, Alignment.center, t)!;
|
||||
final titleFontWeight = FontWeight.lerp(MiniPlayerLayout.primaryFontWeight, FontWeight.w600, t);
|
||||
|
||||
// Collapsed: position above bottom nav bar
|
||||
// Collapsed: position above bottom nav bar (or at bottom edge on detail screens)
|
||||
// Expanded: extend to very bottom (behind system nav bar) for seamless edge-to-edge
|
||||
final bottomNavSpace = _bottomNavHeight + bottomPadding;
|
||||
final effectiveNavHeight = _bottomNavHeight * (1.0 - _detailScreenController.value);
|
||||
final bottomNavSpace = effectiveNavHeight + bottomPadding;
|
||||
final collapsedBottomOffset = bottomNavSpace + _collapsedMargin;
|
||||
final expandedBottomOffset = 0.0; // Extend behind system nav bar
|
||||
final expandedHeight = screenSize.height; // Full screen height for edge-to-edge
|
||||
@@ -2250,6 +2283,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Smoothly fade out horizontal slide offset (for device switching) as animation progresses
|
||||
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
|
||||
|
||||
// Extend gesture hit area above mini player for easier swipe-down,
|
||||
// especially on detail screens where the player sits closer to screen edge.
|
||||
const _gestureExtension = 20.0;
|
||||
|
||||
return Positioned(
|
||||
left: horizontalMargin,
|
||||
right: horizontalMargin,
|
||||
@@ -2475,7 +2512,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Invisible gesture extension area above mini player
|
||||
if (!isExpanded) const SizedBox(height: _gestureExtension),
|
||||
Container(
|
||||
// PERF Phase 4: Only show shadow when meaningfully visible (t < 0.5)
|
||||
// This avoids BoxDecoration allocation for majority of animation frames
|
||||
// Shadow fades out quickly during first half of expansion
|
||||
@@ -3415,6 +3457,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,51 +3,16 @@ import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
import 'expandable_player.dart';
|
||||
import 'player/mini_player_content.dart' show MiniPlayerLayout;
|
||||
import 'player/player_reveal_overlay.dart';
|
||||
|
||||
/// Cached color with contrast adjustment
|
||||
/// Avoids expensive HSL conversions during scroll
|
||||
class _CachedNavColor {
|
||||
Color? _sourceColor;
|
||||
bool? _isDark;
|
||||
Color? _adjustedColor;
|
||||
|
||||
Color getAdjustedColor(Color sourceColor, bool isDark) {
|
||||
// Return cached value if inputs haven't changed
|
||||
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
|
||||
return _adjustedColor!;
|
||||
}
|
||||
|
||||
// Compute new adjusted color
|
||||
var navSelectedColor = sourceColor;
|
||||
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
|
||||
final hsl = HSLColor.fromColor(navSelectedColor);
|
||||
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
|
||||
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
|
||||
final hsl = HSLColor.fromColor(navSelectedColor);
|
||||
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
_sourceColor = sourceColor;
|
||||
_isDark = isDark;
|
||||
_adjustedColor = navSelectedColor;
|
||||
|
||||
return navSelectedColor;
|
||||
}
|
||||
}
|
||||
|
||||
final _cachedNavColor = _CachedNavColor();
|
||||
|
||||
/// A global key to access the player state from anywhere in the app
|
||||
final globalPlayerKey = GlobalKey<ExpandablePlayerState>();
|
||||
|
||||
@@ -197,6 +162,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
// State for player reveal overlay
|
||||
bool _isRevealVisible = false;
|
||||
|
||||
|
||||
// State for interactive hint mode (blur backdrop + wait for user action)
|
||||
bool _isHintModeActive = false;
|
||||
Timer? _hintBounceTimer;
|
||||
@@ -530,138 +496,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
padding: const EdgeInsets.only(bottom: 0), // Content manages its own padding
|
||||
child: widget.child,
|
||||
),
|
||||
// Global persistent bottom navigation bar - positioned at bottom
|
||||
// Hide when: not connected OR showing welcome screen
|
||||
// Always show when connected so users can access Settings/Library even without a player
|
||||
if (isConnected && !shouldShowWelcomeBackdrop)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: ListenableBuilder(
|
||||
listenable: navigationProvider,
|
||||
builder: (context, _) {
|
||||
return Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
// BottomNavigationBar is now in HomeScreen's Scaffold.bottomNavigationBar
|
||||
// for proper Navigator/Overlay ancestry
|
||||
|
||||
return ValueListenableBuilder<PlayerExpansionState>(
|
||||
valueListenable: playerExpansionNotifier,
|
||||
builder: (context, expansionState, _) {
|
||||
// Nav bar color logic - only use adaptive colors when:
|
||||
// 1. Player is expanding/expanded, OR
|
||||
// 2. On a detail screen (isOnDetailScreen)
|
||||
// On home screen with collapsed player: always use default theme colors
|
||||
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
|
||||
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
|
||||
|
||||
// Nav bar background color
|
||||
final Color navBgColor;
|
||||
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
|
||||
// Player is expanding - blend from surface to player's adaptive color
|
||||
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
|
||||
} else if (useAdaptiveColors) {
|
||||
// On a detail screen - use adaptive surface color for nav bar
|
||||
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
|
||||
navBgColor = adaptiveBg ?? colorScheme.surface;
|
||||
} else {
|
||||
// Home screen with collapsed player - use default surface color
|
||||
navBgColor = colorScheme.surface;
|
||||
}
|
||||
|
||||
// Icon color - only use adaptive colors when appropriate
|
||||
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
|
||||
? themeProvider.adaptiveColors!.primary
|
||||
: colorScheme.primary;
|
||||
|
||||
// Blend icon color with player's primary color during expansion
|
||||
Color sourceColor = baseSourceColor;
|
||||
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
|
||||
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
|
||||
}
|
||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||
|
||||
// Fade out and slide down nav bar as player expands
|
||||
// Use IgnorePointer when faded to prevent accidental taps
|
||||
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
|
||||
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: navOpacity < 0.1,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, navSlideDown),
|
||||
child: Opacity(
|
||||
opacity: navOpacity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: navBgColor,
|
||||
boxShadow: expansionState.progress < 0.5
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
// Clear adaptive colors when switching tabs (detail screen colors shouldn't persist)
|
||||
themeProvider.clearAdaptiveColors();
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
activeIcon: const Icon(Icons.home_rounded),
|
||||
label: S.of(context)!.home,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Symbols.book_2, fill: 0),
|
||||
activeIcon: const Icon(Symbols.book_2, fill: 1),
|
||||
label: S.of(context)!.library,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
activeIcon: const Icon(Icons.search_rounded),
|
||||
label: S.of(context)!.search,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
activeIcon: const Icon(Icons.settings_rounded),
|
||||
label: S.of(context)!.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Blur backdrop for device selector (reveal mode) - static, no animation
|
||||
if (_isRevealVisible && !_isHintModeActive)
|
||||
Positioned.fill(
|
||||
@@ -788,8 +625,9 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
PlayerRevealOverlay(
|
||||
key: _revealKey,
|
||||
onDismiss: _hidePlayerReveal,
|
||||
// Use viewPadding to match BottomNavigationBar's height calculation
|
||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
|
||||
// Use viewPadding to match BottomNavigationBar's height calculation.
|
||||
// On detail screens the nav bar is hidden so exclude its height.
|
||||
miniPlayerBottom: (context.read<ThemeProvider>().isOnDetailScreen ? 0.0 : BottomSpacing.navBarHeight) + MediaQuery.of(context).viewPadding.bottom + 12,
|
||||
miniPlayerHeight: 64,
|
||||
showOnboardingHints: _isOnboardingReveal,
|
||||
// Pass pending action parameters for "Play On" functionality
|
||||
@@ -800,15 +638,16 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
|
||||
// Global player overlay - renders ON TOP so cards slide behind it
|
||||
// Use Selector instead of Consumer to avoid rebuilds during animation
|
||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack})>(
|
||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack, bool hasCachedPlayers})>(
|
||||
selector: (_, provider) => (
|
||||
isConnected: provider.isConnected,
|
||||
hasPlayer: provider.selectedPlayer != null,
|
||||
hasTrack: provider.currentTrack != null,
|
||||
hasCachedPlayers: provider.hasCachedPlayers,
|
||||
),
|
||||
builder: (context, state, child) {
|
||||
// Only show player if connected and has a selected player
|
||||
if (!state.isConnected || !state.hasPlayer) {
|
||||
// Show player if connected with a player, or if cached players exist (brief disconnect)
|
||||
if ((!state.isConnected && !state.hasCachedPlayers) || !state.hasPlayer) {
|
||||
// Reset expansion state to prevent stale values from causing
|
||||
// invisible/non-interactive nav bar when connection restores.
|
||||
// This fixes a bug where expanded player state persisted after
|
||||
|
||||
@@ -69,46 +69,54 @@ class HiResBadge extends StatelessWidget {
|
||||
final baseColor = primaryColor ?? colorScheme.primary;
|
||||
final color = Color.lerp(baseColor, Colors.white, 0.5)!;
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip ?? 'Hi-Res Audio',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: 1.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Hi-Res',
|
||||
final content = Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color, width: 1.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Hi-Res',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
'AUDIO',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.surface,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
'AUDIO',
|
||||
style: TextStyle(
|
||||
color: colorScheme.surface,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Only use Tooltip if an Overlay ancestor exists
|
||||
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||
if (!hasOverlay) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip ?? 'Hi-Res Audio',
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
|
||||
/// A widget that rebuilds when the library/favorite status of an item changes.
|
||||
@@ -123,9 +124,16 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
|
||||
|
||||
void _onLibraryStatusChanged() {
|
||||
// Trigger rebuild to pick up new status values
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
// Defer setState to avoid calling it during build phase
|
||||
if (!mounted) return;
|
||||
|
||||
// Use Future.microtask to defer setState until after the current build phase
|
||||
// This prevents "setState during build" errors while avoiding Overlay issues
|
||||
Future.microtask(() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Set library status optimistically and return previous value for rollback
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -100,12 +100,12 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
// Preload all player track info and extract colors
|
||||
_preloadColorsForPlayers();
|
||||
|
||||
// Start reveal animation
|
||||
// Start reveal animation first, then load colors after it settles
|
||||
// (color extraction triggers setState which causes jitter during animation)
|
||||
_revealController.duration = const Duration(milliseconds: 200);
|
||||
_revealController.forward();
|
||||
_revealController.forward().then((_) {
|
||||
if (mounted) _preloadColorsForPlayers();
|
||||
});
|
||||
|
||||
// Auto-refresh player data
|
||||
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
||||
@@ -266,15 +266,23 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
final hintColor = colorScheme.onSurface.withOpacity(0.7);
|
||||
|
||||
// Back gesture is handled at GlobalPlayerOverlay level
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
final allPlayers = maProvider.availablePlayers;
|
||||
// Use Selector to only rebuild when the player list actually changes,
|
||||
// not on every provider notification (prevents jitter during animation)
|
||||
return Selector<MusicAssistantProvider, ({List<dynamic> players, String? selectedId, int playerCount})>(
|
||||
selector: (_, p) => (
|
||||
players: p.availablePlayers,
|
||||
selectedId: p.selectedPlayer?.playerId,
|
||||
playerCount: p.availablePlayers.length,
|
||||
),
|
||||
shouldRebuild: (prev, next) => prev.playerCount != next.playerCount || prev.selectedId != next.selectedId,
|
||||
builder: (context, data, child) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final allPlayers = data.players;
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
|
||||
// Filter out the selected player - it's already shown in the mini player
|
||||
final players = allPlayers
|
||||
.where((p) => p.playerId != selectedPlayer?.playerId)
|
||||
.where((p) => p.playerId != data.selectedId)
|
||||
.toList();
|
||||
|
||||
// Default card colors - used when player doesn't have extracted colors
|
||||
|
||||
@@ -806,14 +806,12 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
),
|
||||
onPressed: () => _handleTransferQueue(context),
|
||||
padding: Spacing.paddingAll12,
|
||||
tooltip: S.of(context)!.transferQueueToPlayer,
|
||||
),
|
||||
// Clear queue button
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||
onPressed: _handleClearQueue,
|
||||
padding: Spacing.paddingAll12,
|
||||
tooltip: S.of(context)!.clearQueue,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# Pull Request: Group Volume Management, Hardware Volume Integration, and Bugfixes
|
||||
|
||||
## Summary
|
||||
|
||||
This PR represents a testing and debugging cycle following v3.0.2. Initial work on sync group volume management uncovered multiple UI bugs that required fixes. The final result addresses:
|
||||
|
||||
1. **Fix: Group Player Volume Management** - Two distinct volume control issues for Music Assistant sync groups
|
||||
2. **Feature: Hardware Volume Button Integration** - Phone volume buttons now control Music Assistant player volume from lockscreen / shade widget / app minimised
|
||||
3. **UI Bug Fixes** - Five UI issues discovered during sync group testing, which had to be fixed to get the sync groups to work without crashes
|
||||
4. **Fix: Cold Start Homepage Race Condition** - Intermittent empty homepage on app cold start - surfaced once sync group volume calculations were introduced.
|
||||
5. **Code Quality: Cleanup and Optimization** - Removes dead code and eliminates duplicate logic - mine, not yours, I tried to not to touch your code as much as possible
|
||||
|
||||
All changes have been tested and verified with `dart analyze` showing no new issues.
|
||||
|
||||
---
|
||||
|
||||
## Group Volume Management Fixes
|
||||
|
||||
### 1. Group Player Volume Management
|
||||
|
||||
**Issue:** Volume control for Music Assistant sync groups had two distinct problems depending on how the group was created:
|
||||
|
||||
1. **Server-side groups**: Volume slider would snap back to 0 immediately after release
|
||||
2. **In-app created groups**: Adjusting the leader's volume only changed that one player, not all members proportionally
|
||||
|
||||
#### Part A: Server-Side Groups (Null Volume Snap-back)
|
||||
|
||||
**Root Cause:** The Music Assistant API returns `volume_level: null` for certain group players (pre-configured speaker groups with `provider == 'player_group'` and some server-side sync groups). The app had no logic to handle this case, so the null volume was interpreted as 0.
|
||||
|
||||
**Solution:** Implemented `GroupVolumeManager` service with dual-tier volume protection:
|
||||
|
||||
- **Tier 1 (GroupVolumeManager):** Computes effective volume from group members (detects groups, resolves nested, averages volumes, caches pending for 30s, confirms with ±3 tolerance).
|
||||
- **Tier 2 (Provider-level):** Optimistic update protection (caches for 8s/30s, prevents stale overwrites).
|
||||
|
||||
#### Part B: In-App Created Groups (Proportional Volume Propagation)
|
||||
|
||||
**Root Cause:** For in-app groups, leader has non-null volume, so `setVolume()` only affects leader, not members.
|
||||
|
||||
**Expected behavior:** Leader adjustment changes all members proportionally; individual adjustments affect only that member.
|
||||
|
||||
**Solution:** In `setVolume()`, for group leaders with non-null volume:
|
||||
1. Calculate proportional: `memberNewVol = (memberCurrentVol * newVol / leaderCurrentVol).round()` (handle zero case with absolute).
|
||||
2. Cache pending, update optimistically, fire parallel API calls.
|
||||
|
||||
**Files Changed:**
|
||||
- `lib/services/group_volume_manager.dart` (NEW - 162 lines)
|
||||
- `lib/providers/music_assistant_provider.dart` (added manager, helper, updated events/state/volume methods)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### 2. Hardware Volume Button Integration (Convenience Feature)
|
||||
|
||||
**Feature:** Phone hardware volume buttons (including lockscreen) control MA player volume, with system HUD mirroring.
|
||||
|
||||
**Implementation:**
|
||||
- **Android Native:** `MainActivity.kt` (~120 lines) - VolumeContentObserver, MethodChannel commands, lifecycle.
|
||||
- **Flutter Service:** `hardware_volume_service.dart` (~65 lines) - streams, observer methods, sync.
|
||||
- **Integration:** `main.dart` (~35 lines) - adjust/set/update methods, subscription.
|
||||
|
||||
**Why This Matters:** Seamless remote control from lockscreen/tray/buttons.
|
||||
|
||||
---
|
||||
|
||||
## UI Bug Fixes
|
||||
|
||||
During sync group testing, fixed these UI bugs:
|
||||
|
||||
### 3A. Zone Mismatch Warning
|
||||
|
||||
**File:** `lib/main.dart`
|
||||
|
||||
**Issue:** Console warnings during init.
|
||||
|
||||
**Root Cause:** Init code outside `runZonedGuarded()`.
|
||||
|
||||
**Fix:** Move init inside `runZonedGuarded()`.
|
||||
|
||||
---
|
||||
|
||||
### 3B. BottomNavigationBar "No Overlay" Crash
|
||||
|
||||
**Files:** `lib/screens/home_screen.dart`, `lib/widgets/global_player_overlay.dart`
|
||||
|
||||
**Issue:** Crash on nav interaction.
|
||||
|
||||
**Root Cause:** Nav in Stack without Overlay.
|
||||
|
||||
**Fix:** Move nav to Scaffold.bottomNavigationBar, add extendBody: true.
|
||||
|
||||
---
|
||||
|
||||
### 3C. setState During Build Warning (Artist/Album Cards)
|
||||
|
||||
**Files:** `lib/widgets/artist_card.dart`, `lib/widgets/album_card.dart`
|
||||
|
||||
**Issue:** Warnings causing corruption.
|
||||
|
||||
**Root Cause:** Service calls in initState trigger setState during build.
|
||||
|
||||
**Fix:** Defer with addPostFrameCallback, add mounted guard.
|
||||
|
||||
---
|
||||
|
||||
### 3D. context.select Inside SliverList Crash
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
**Issue:** Crash on library tabs.
|
||||
|
||||
**Root Cause:** context.select in sliver context.
|
||||
|
||||
**Fix:** Use Consumer wrappers for tabs.
|
||||
|
||||
---
|
||||
|
||||
### 3E. QueuePanel Tooltip "No Overlay" Error
|
||||
|
||||
**File:** `lib/widgets/player/queue_panel.dart`
|
||||
|
||||
**Issue:** Crash on long-press buttons.
|
||||
|
||||
**Root Cause:** Tooltips require Overlay, but in Stack.
|
||||
|
||||
**Fix:** Remove tooltip properties.
|
||||
|
||||
---
|
||||
|
||||
### 3F. Settings Back Button Crash
|
||||
|
||||
**File:** `lib/screens/settings_screen.dart`
|
||||
|
||||
**Issue:** Back button crashes to black screen.
|
||||
|
||||
**Root Cause:** Navigator.pop pops home screen.
|
||||
|
||||
**Fix:** Use showPlayer + setSelectedIndex(0).
|
||||
|
||||
---
|
||||
|
||||
## Cold Start Homepage Race Condition Fix
|
||||
|
||||
### 4. Cold Start Homepage Empty/No Refresh
|
||||
|
||||
**Issue:** Intermittent empty homepage on cold start.
|
||||
|
||||
**Root Cause:** isConnected true before auth complete, leading to failed fetches.
|
||||
|
||||
**Solution:** Delay broadcasting 'connected' state until auth if required (`music_assistant_provider.dart:1058-1101`).
|
||||
|
||||
---
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### GroupVolumeManager Architecture
|
||||
|
||||
The `GroupVolumeManager` is a stateful service that handles **server-side groups with null volume**:
|
||||
|
||||
1. **Detects group players:**
|
||||
```dart
|
||||
bool isGroupPlayer(Player player) {
|
||||
// Pre-configured MA speaker groups
|
||||
if (player.provider == 'player_group') return true;
|
||||
// Dynamic sync groups: have multiple members but MA returns null volume
|
||||
if (player.groupMembers != null &&
|
||||
player.groupMembers!.length > 1 &&
|
||||
player.volumeLevel == null) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Computes effective volume:**
|
||||
- Recursively resolves nested groups (max depth 3)
|
||||
- Averages volumes across all leaf members
|
||||
- Skips self-references in `groupMembers`
|
||||
- Returns `null` if no members have valid volumes
|
||||
|
||||
3. **Caches pending volumes:**
|
||||
- When user sets volume: stored with timestamp
|
||||
- Returns cached value if within 30s timeout
|
||||
- Clears cache when member average converges (±3 tolerance)
|
||||
- Falls back to computed average after timeout
|
||||
|
||||
4. **Integrates with provider:**
|
||||
- Called from `_applyVolumeProtection()` in provider
|
||||
- Receives fresh `allPlayers` list for accurate member volumes
|
||||
- Injects effective volume into `_selectedPlayer` via `copyWith()`
|
||||
|
||||
### Proportional Volume Propagation (In-App Groups)
|
||||
|
||||
For **in-app created groups with non-null leader volume**, proportional propagation works as follows:
|
||||
|
||||
1. **Detection:** `setVolume()` checks if `player.isGroupLeader && !isGroupPlayer(player)`
|
||||
- Leader has `groupMembers` with 2+ entries
|
||||
- Leader has its own non-null `volumeLevel`
|
||||
- NOT detected as a group player (GroupVolumeManager handles those)
|
||||
|
||||
2. **Calculation:** For each member in `groupMembers` (excluding leader itself):
|
||||
```dart
|
||||
if (leaderCurrentVol > 0) {
|
||||
memberNewVol = (memberVol * newVol / leaderCurrentVol).round().clamp(0, 100)
|
||||
} else {
|
||||
memberNewVol = newVol // Can't ratio from zero
|
||||
}
|
||||
```
|
||||
|
||||
3. **Execution:**
|
||||
- Cache `_pendingVolumes[memberId]` and `_pendingVolumeTimestamps[memberId]`
|
||||
- Update `_availablePlayers[idx]` optimistically
|
||||
- Fire `_api?.setVolume(memberId, memberNewVol)` without awaiting
|
||||
- Continue to send leader's own `volume_set` command
|
||||
|
||||
4. **Result:** All member volumes scale proportionally with the leader, preserving relative balance.
|
||||
|
||||
### Connection State Flow (After Fix)
|
||||
|
||||
```
|
||||
WebSocket connects → MAConnectionState.connected
|
||||
↓
|
||||
Auth required?
|
||||
↙ ↘
|
||||
YES NO
|
||||
↓ ↓
|
||||
Don't notify Set state + notify
|
||||
Try auth ↓
|
||||
↓ Initialize
|
||||
Success?
|
||||
↙ ↘
|
||||
YES NO
|
||||
↓ ↓
|
||||
auth error state
|
||||
event + notify
|
||||
↓
|
||||
Set state
|
||||
+ notify
|
||||
↓
|
||||
Initialize
|
||||
```
|
||||
|
||||
**Key:** Listeners only see `isConnected = true` after auth completes (or immediately if no auth required).
|
||||
|
||||
|
||||
## Files Modified
|
||||
android/.../MainActivity.kt | 120 + (NEW - ContentObserver)
|
||||
lib/main.dart | 207 ±
|
||||
lib/providers/music_assistant_provider.dart | 165 ±
|
||||
lib/screens/home_screen.dart | 172 ± (BottomNav moved here)
|
||||
lib/screens/new_library_screen.dart | 70 ± (Consumer wrappers)
|
||||
lib/screens/settings_screen.dart | 9 ± (back button fix)
|
||||
lib/services/group_volume_manager.dart | 162 + (NEW)
|
||||
lib/services/hardware_volume_service.dart | 65 + (NEW)
|
||||
lib/widgets/album_card.dart | 23 ± (postFrameCallback)
|
||||
lib/widgets/artist_card.dart | 23 ± (postFrameCallback)
|
||||
lib/widgets/global_player_overlay.dart | 171 - (BottomNav moved out)
|
||||
lib/widgets/player/queue_panel.dart | 2 - (tooltip removed)
|
||||
|
||||
**Total:** ~650 added, ~300 removed/refactored.
|
||||
+35
-3
@@ -351,7 +351,7 @@ packages:
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||
@@ -854,7 +854,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
@@ -1106,6 +1106,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.28"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.6"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1114,6 +1138,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1252,4 +1284,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 3.0.2+54
|
||||
version: 3.0.5+57
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -27,6 +27,7 @@ dependencies:
|
||||
web_socket_channel: ^2.4.0
|
||||
http: ^1.1.0
|
||||
cached_network_image: ^3.3.1
|
||||
flutter_cache_manager: ^3.3.1
|
||||
|
||||
# Storage
|
||||
shared_preferences: ^2.2.2
|
||||
@@ -42,12 +43,14 @@ dependencies:
|
||||
package_info_plus: ^8.0.0
|
||||
device_info_plus: ^10.1.0
|
||||
share_plus: ^10.1.4
|
||||
url_launcher: ^6.3.0
|
||||
|
||||
# Audio - Local Playback
|
||||
just_audio: ^0.9.36
|
||||
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