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 | ||
|
|
00f065fbe9 | ||
|
|
e440f7b1d7 | ||
|
|
5a73d2d65b | ||
|
|
3b5a8631a8 | ||
|
|
1f3a01c22f | ||
|
|
27e1b8b746 | ||
|
|
9be3737a9d | ||
|
|
882e8b2ea4 | ||
|
|
aef9423b42 | ||
|
|
c0138828a7 | ||
|
|
53225138a2 | ||
|
|
ebc3288bfc | ||
|
|
5bd0cef76e | ||
|
|
9f399b438f | ||
|
|
63473226f5 | ||
|
|
e3d257a6dc | ||
|
|
671b31de61 | ||
|
|
67a4ebd771 | ||
|
|
23af5379a6 | ||
|
|
86bdad5534 | ||
|
|
7ee27f6e75 | ||
|
|
633f489bfa | ||
|
|
61503cbe49 | ||
|
|
0b49c5158c | ||
|
|
d7b7bbcf0d | ||
|
|
3503978185 | ||
|
|
020ed250f4 | ||
|
|
e5be64a6cd | ||
|
|
b85fadc551 | ||
|
|
3b8339f94f | ||
|
|
c00fb3dc78 | ||
|
|
ff34479169 | ||
|
|
d7127ea7da | ||
|
|
29b4887d0d | ||
|
|
18603c34e1 | ||
|
|
5ed2bf7725 | ||
|
|
67a99c88a0 | ||
|
|
ffbb41580b | ||
|
|
6942b56cfb | ||
|
|
14ba524ed0 | ||
|
|
49fc2a77e7 | ||
|
|
6632fa14c6 | ||
|
|
a0cac2174f | ||
|
|
056d1fb854 | ||
|
|
a4cc7115bf | ||
|
|
1f3188fa6c | ||
|
|
9527ba341e | ||
|
|
79e9e02f53 | ||
|
|
d14a4b99c6 | ||
|
|
7e72b08f44 | ||
|
|
801952482f | ||
|
|
bae1154bcf |
@@ -0,0 +1,3 @@
|
||||
# These are the funding models supported by Ensemble
|
||||
github: CollotsSpot
|
||||
custom: https://ko-fi.com/collotsspot
|
||||
@@ -31,8 +31,8 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.24.0'
|
||||
channel: 'stable'
|
||||
flutter-version: '3.38.0'
|
||||
channel: 'master'
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
@@ -63,10 +63,7 @@ jobs:
|
||||
|
||||
- name: Setup signing keystore
|
||||
run: |
|
||||
# Decode keystore from secret (persistent key for consistent signatures)
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
|
||||
|
||||
# Create key.properties file
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword=${{ secrets.KEY_PASSWORD }}
|
||||
@@ -75,12 +72,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: |
|
||||
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
|
||||
mkdir -p ~/.gradle
|
||||
cp android/init.gradle ~/.gradle/init.gradle
|
||||
|
||||
flutter build apk --release
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -91,10 +83,5 @@ jobs:
|
||||
|
||||
- name: Upload APK summary
|
||||
run: |
|
||||
echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📱 **APK Location:** \`build/app/outputs/flutter-apk/app-release.apk\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📦 **Download:** Check the artifacts section below" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## APK Build Successful!" >> $GITHUB_STEP_SUMMARY
|
||||
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -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
|
||||
@@ -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**
|
||||
@@ -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.*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -36,7 +48,8 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
### Queue Management
|
||||
- **View & Manage Queue** - See upcoming tracks in the playback queue
|
||||
- **Drag to Reorder** - Instant drag handles for reordering tracks
|
||||
- **Swipe to Delete** - Remove tracks with a simple swipe gesture
|
||||
- **Swipe left to Delete** - Remove tracks with a simple swipe gesture
|
||||
- **Swipe right to play next** - Move tracks with a simple swipe gesture
|
||||
|
||||
### Home Screen
|
||||
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
|
||||
@@ -50,7 +63,9 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
|
||||
- **Audiobooks** - Browse by title, series, or author with progress tracking
|
||||
- **Favorites Filter** - Toggle to show only your favorite items
|
||||
- **Providers Filter** - Toggle to show only specific provider items
|
||||
- **Letter Scrollbar** - Fast navigation through long lists
|
||||
- **Provider Icons** - Provider icons on library covers
|
||||
|
||||
### Search
|
||||
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
|
||||
@@ -71,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
|
||||
@@ -86,16 +108,18 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
|
||||
<img src="assets/screenshots/1.png?v=4" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=4" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=4" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=4" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=4" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=4" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=4" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=4" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=4" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=4" alt="Screenshot 10" width="150">
|
||||
<img src="assets/screenshots/11.png?v=4" alt="Screenshot 11" width="150">
|
||||
<img src="assets/screenshots/12.png?v=4" alt="Screenshot 12" width="150">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
@@ -122,25 +146,22 @@ To find the correct URL:
|
||||
|
||||
If you run Music Assistant as a Home Assistant add-on:
|
||||
- Use the IP address of your Home Assistant server
|
||||
- Enter `8095` in the port field
|
||||
- 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., Nginx Proxy Manager, Cloudflare Tunnel).
|
||||
|
||||
## Authentication
|
||||
|
||||
Ensemble supports multiple authentication methods:
|
||||
|
||||
| Method | Status |
|
||||
|--------|--------|
|
||||
| Music Assistant native auth | Tested |
|
||||
| No authentication | Tested |
|
||||
| Authelia | Implemented, not recently tested |
|
||||
| HTTP Basic Auth | Implemented, not recently tested |
|
||||
|
||||
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
|
||||
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).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -149,6 +170,13 @@ Ensemble supports multiple authentication methods:
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Release Notes Draft
|
||||
|
||||
## v2.9.2-beta (TBD)
|
||||
|
||||
### New Features
|
||||
|
||||
#### Context Menus
|
||||
- **Long-press context menus on all media items**: Albums, artists, tracks, playlists, podcasts, and radio stations now all support long-press to show a context menu with Play, Add to Library, and Favorite options.
|
||||
|
||||
#### Podcasts
|
||||
- **Podcast episode detail screen**: Tap "View Details" from episode context menu to see full scrollable description, publish date, duration, with Play, Play On, and Add to Queue actions.
|
||||
- **Episode sorting**: Sort podcast episodes by newest first, oldest first, alphabetical, or duration.
|
||||
|
||||
#### Audio Quality
|
||||
- **Hi-res audio badge**: Track listings now show a hi-res badge when high quality audio is available.
|
||||
|
||||
### Improvements
|
||||
|
||||
#### UI/UX
|
||||
- **New library icon**: Replaced confusing library icon with clearer book icon (book_2) across bottom nav, search filter, detail screens, and context menus. Filled when in library, outlined when not.
|
||||
- **Player selector**: Replaced popup dialog with player select overlay for queue actions.
|
||||
- **Search library filter**: Filled icon indicates active filter state without background highlight.
|
||||
- **Context menu polish**: Slightly larger buttons (46px), wider menu (185px), consistent text colors that don't pick up adaptive theming.
|
||||
- **Navigation transitions**: Smoother fade/slide animations between screens.
|
||||
- **Hero animations**: Podcast episode images animate smoothly to detail screen.
|
||||
|
||||
#### Performance
|
||||
- **Library list optimization**: Standardized list item heights with itemExtent, reduced image cache sizes for smoother scrolling.
|
||||
- **Palette extraction**: Moved to isolate for better UI responsiveness during color extraction.
|
||||
- **Provider selects**: Optimized provider state watching to reduce unnecessary rebuilds.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### Audiobooks
|
||||
- **Fixed audiobooks not loading after provider re-add**: When removing and re-adding an AudiobookShelf provider, the app would show 0 audiobooks because saved library paths referenced the old provider instance ID. Now automatically clears stale settings and falls back to the library_items API.
|
||||
|
||||
- **Fixed duplicate provider filter options**: After re-adding a provider, two entries for the same provider could appear in the library filter menu. Now deduplicates providers by name, keeping the one with more items.
|
||||
|
||||
- **Fixed multiple accounts of same service not showing**: When you have multiple accounts of the same service (e.g., two Spotify accounts for different family members), only one would appear in the library menu provider toggles. Now all accounts with distinct names are shown.
|
||||
|
||||
- **Fixed audiobook year sorting**: Sort by year now works correctly for audiobooks. Fixed two issues:
|
||||
- Year data is now properly extracted from ABS's `metadata.release_date` field (handles both "2024" and "2024-01-15" formats)
|
||||
- Sort handler now properly supports ascending and descending year sorts
|
||||
|
||||
#### Library Sorting
|
||||
- **Fixed letter scrollbar showing irrelevant letters**: When sorting by date or play count, the scrollbar popup now hides instead of showing meaningless letters. Shows contextual info based on sort type (letters for alphabetical, years for year sort, hidden for date/count sorts).
|
||||
|
||||
- **Fixed sorting not working for date added, last played, play count**: These sort options now correctly preserve server-side sorting instead of re-sorting alphabetically on the client. Affected albums, artists, and tracks tabs.
|
||||
|
||||
- **Fixed playlists sorting**: Modified, last played, and play count sorting now works for playlists by properly requesting sorted data from the server.
|
||||
|
||||
- **Fixed ascending/descending not working**: Sort direction now properly applies for all sort types across albums, artists, playlists, and audiobooks.
|
||||
|
||||
- **Fixed list not updating when sort order changes**: Added sort order to ListView/GridView keys to force UI rebuilds when changing sort direction.
|
||||
|
||||
#### Provider Management
|
||||
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
|
||||
|
||||
#### Playback
|
||||
- **Fixed Spotify external source detection** (issue #27): Properly detects when Spotify is the music provider.
|
||||
- **Fixed foreground service not stopping** (issue #51): Auto-stops after 30 minutes idle.
|
||||
- **Fixed Sendspin proxy auth**: Now works correctly for local network connections.
|
||||
|
||||
#### UI Fixes
|
||||
- **Fixed nav bar hidden when no player selected**: Navigation bar now always visible.
|
||||
- **Fixed Flutter 3.38 slider grey rectangles**: Volume and seek sliders render correctly.
|
||||
- **Fixed library tab scroll position**: Maintains position when switching tabs.
|
||||
|
||||
### Technical Changes
|
||||
- Flutter 3.38 compatibility
|
||||
- Simplified authentication using MA built-in auth
|
||||
- Added `material_symbols_icons` package for book_2 icon
|
||||
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
|
||||
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
|
||||
- Provider filter now validates against available providers when toggling, not just on initial load
|
||||
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
|
||||
- Options menu provider toggles now use Consumer to watch Provider state directly
|
||||
- Centralized player selector logic in GlobalPlayerOverlay.showPlayerSelectorForAction()
|
||||
@@ -29,8 +29,8 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.musicassistant.music_assistant"
|
||||
compileSdk 35
|
||||
namespace "com.collotsspot.ensemble"
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
@@ -58,8 +58,8 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
minSdk 21
|
||||
applicationId "com.collotsspot.ensemble"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
@@ -70,6 +70,21 @@ android {
|
||||
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
|
||||
// while also intercepting volume button events
|
||||
class MainActivity: AudioServiceActivity() {
|
||||
private val TAG = "EnsembleVolume"
|
||||
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
|
||||
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}")
|
||||
when (call.method) {
|
||||
"startListening" -> {
|
||||
isListening = true
|
||||
Log.d(TAG, "Volume listening ENABLED")
|
||||
result.success(null)
|
||||
}
|
||||
"stopListening" -> {
|
||||
isListening = false
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if (event == null) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
val keyCode = event.keyCode
|
||||
val action = event.action
|
||||
|
||||
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
|
||||
|
||||
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
|
||||
if (action != KeyEvent.ACTION_DOWN) {
|
||||
// For volume keys when listening, also consume ACTION_UP to fully block system volume
|
||||
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
|
||||
Log.d(TAG, "Consuming ACTION_UP for volume key")
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
if (!isListening) {
|
||||
Log.d(TAG, "Not listening, passing to super")
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
|
||||
methodChannel?.invokeMethod("volumeUp", null)
|
||||
true // Consume the event to prevent system volume change
|
||||
}
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
|
||||
methodChannel?.invokeMethod("volumeDown", null)
|
||||
true // Consume the event to prevent system volume change
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Other key, passing to super")
|
||||
super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package com.musicassistant.music_assistant
|
||||
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
|
||||
// while also intercepting volume button events
|
||||
class MainActivity: AudioServiceActivity() {
|
||||
private val TAG = "EnsembleVolume"
|
||||
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var isListening = false
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
|
||||
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
Log.d(TAG, "Received method call: ${call.method}")
|
||||
when (call.method) {
|
||||
"startListening" -> {
|
||||
isListening = true
|
||||
Log.d(TAG, "Volume listening ENABLED")
|
||||
result.success(null)
|
||||
}
|
||||
"stopListening" -> {
|
||||
isListening = false
|
||||
Log.d(TAG, "Volume listening DISABLED")
|
||||
result.success(null)
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Unknown method: ${call.method}")
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
|
||||
// and may consume events before they reach onKeyDown
|
||||
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
|
||||
if (event == null) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
val keyCode = event.keyCode
|
||||
val action = event.action
|
||||
|
||||
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
|
||||
|
||||
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
|
||||
if (action != KeyEvent.ACTION_DOWN) {
|
||||
// For volume keys when listening, also consume ACTION_UP to fully block system volume
|
||||
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
|
||||
Log.d(TAG, "Consuming ACTION_UP for volume key")
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
if (!isListening) {
|
||||
Log.d(TAG, "Not listening, passing to super")
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
|
||||
methodChannel?.invokeMethod("volumeUp", null)
|
||||
true // Consume the event to prevent system volume change
|
||||
}
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
|
||||
methodChannel?.invokeMethod("volumeDown", null)
|
||||
true // Consume the event to prevent system volume change
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Other key, passing to super")
|
||||
super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,16.5c-2.49,0 -4.5,-2.01 -4.5,-4.5S9.51,7.5 12,7.5s4.5,2.01 4.5,4.5 -2.01,4.5 -4.5,4.5zM12,11c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55C7.79,13 6,14.79 6,17s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M15,6H3v2h12V6zM15,10H3v2h12V10zM3,16h8v-2H3V16zM17,6v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3V8h3V6H17z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M3.24,6.15C2.51,6.43 2,7.17 2,8v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2H8.3l8.26,-3.34 -0.37,-0.92L3.24,6.15zM7,20c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM20,12H12V9h8v3z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -1,15 +1,12 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.10'
|
||||
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.1.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.0-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -22,8 +22,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.1.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
|
||||
id "com.android.application" version "8.9.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 909 KiB |
@@ -0,0 +1,336 @@
|
||||
# Per-Provider Sync Implementation
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This document details the implementation of per-provider sync with source tracking, enabling instant client-side filtering that properly differentiates between multiple accounts of the same provider type (e.g., two different Spotify accounts).
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### The Core Issue
|
||||
|
||||
When a user has multiple accounts of the same provider type (e.g., two Spotify accounts: "Chris Laycock" and "Bababagoly"), the existing provider filter couldn't differentiate between them.
|
||||
|
||||
**Why?** Music Assistant's `provider_mappings` field on media items shows which providers **CAN PLAY** an item, not which provider **ADDED** it. When both Spotify accounts can play the same artist, both appear in `provider_mappings`, making client-side filtering impossible.
|
||||
|
||||
### Previous Approach (Failed)
|
||||
|
||||
The initial implementation tried to filter client-side using `provider_mappings`:
|
||||
|
||||
```dart
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
for (final mapping in mappings) {
|
||||
if (_providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Both Spotify accounts showed the same items because `provider_mappings` contained both account IDs for shared content.
|
||||
|
||||
### Race Condition Issue
|
||||
|
||||
Additionally, when rapidly toggling providers, a race condition occurred:
|
||||
1. User toggles Provider A off
|
||||
2. Sync starts for remaining providers
|
||||
3. User toggles Provider B off before sync completes
|
||||
4. First sync completes with wrong filter state
|
||||
5. UI shows incorrect results
|
||||
|
||||
## Solution: Per-Provider Sync with Source Tracking
|
||||
|
||||
### Concept
|
||||
|
||||
Instead of relying on `provider_mappings`, we:
|
||||
1. **Sync each provider separately** using the MA API's `provider` parameter
|
||||
2. **Tag each item** with which provider instance(s) returned it
|
||||
3. **Store source tracking** in the local database
|
||||
4. **Filter client-side** using the source tracking data (instant)
|
||||
5. **Background sync** keeps data fresh without blocking UI
|
||||
|
||||
### Why This Works
|
||||
|
||||
When we call the MA API with `provider: ["spotify--RKnr3jKx"]`, the server returns **only items from that specific account's library**. By syncing each provider separately and remembering which provider returned each item, we can accurately filter.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Schema Update
|
||||
|
||||
**File:** `lib/database/database.dart`
|
||||
|
||||
Added `sourceProviders` column to `LibraryCache` table:
|
||||
|
||||
```dart
|
||||
class LibraryCache extends Table {
|
||||
// ... existing columns ...
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
}
|
||||
```
|
||||
|
||||
**Schema version:** 5 → 6
|
||||
|
||||
**Migration:**
|
||||
```dart
|
||||
if (from < 6) {
|
||||
await customStatement(
|
||||
"ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Service Updates
|
||||
|
||||
**File:** `lib/services/database_service.dart`
|
||||
|
||||
Added `sourceProvider` parameter to `cacheItem()`:
|
||||
|
||||
```dart
|
||||
Future<void> cacheItem<T>({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required Map<String, dynamic> data,
|
||||
String? sourceProvider, // NEW: tracks which provider this item came from
|
||||
})
|
||||
```
|
||||
|
||||
Added new method to retrieve items with source tracking:
|
||||
|
||||
```dart
|
||||
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType)
|
||||
```
|
||||
|
||||
Returns tuples of `(itemData, sourceProviderIds)`.
|
||||
|
||||
### 3. SyncService Rewrite
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
#### New Fields
|
||||
|
||||
```dart
|
||||
// Source provider tracking for client-side filtering
|
||||
Map<String, List<String>> _albumSourceProviders = {};
|
||||
Map<String, List<String>> _artistSourceProviders = {};
|
||||
Map<String, List<String>> _audiobookSourceProviders = {};
|
||||
Map<String, List<String>> _playlistSourceProviders = {};
|
||||
```
|
||||
|
||||
#### Per-Provider Sync Logic
|
||||
|
||||
```dart
|
||||
Future<void> syncFromApi(MusicAssistantAPI api, {
|
||||
bool force = false,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
// If specific providers are requested, sync each separately
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
for (final providerId in providerInstanceIds) {
|
||||
// Fetch from this specific provider
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
|
||||
api.getArtists(limit: 1000, providerInstanceIds: [providerId]),
|
||||
// ... etc
|
||||
]);
|
||||
|
||||
// Track source provider for each item
|
||||
for (final album in albums) {
|
||||
albumMap[album.itemId] = album;
|
||||
_albumSourceProviders.putIfAbsent(album.itemId, () => []);
|
||||
if (!_albumSourceProviders[album.itemId]!.contains(providerId)) {
|
||||
_albumSourceProviders[album.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Client-Side Filtering Methods
|
||||
|
||||
```dart
|
||||
/// Filter albums by source provider (instant, no network)
|
||||
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty || _albumSourceProviders.isEmpty) {
|
||||
return _cachedAlbums;
|
||||
}
|
||||
return _cachedAlbums.where((album) {
|
||||
final sources = _albumSourceProviders[album.itemId];
|
||||
if (sources == null || sources.isEmpty) return true; // No tracking = show
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Similar methods for artists, audiobooks, playlists
|
||||
```
|
||||
|
||||
### 4. Library Screen Updates
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
#### Hybrid Provider Toggle Handler
|
||||
|
||||
```dart
|
||||
void _handleProviderToggle(String providerId, bool enabled) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Instant UI update via global provider toggle
|
||||
maProvider.toggleProviderEnabled(providerId, enabled);
|
||||
setState(() {}); // Rebuild with client-side filtering
|
||||
|
||||
// Background sync after 1.5s debounce
|
||||
_providerFilterDebounce?.cancel();
|
||||
_providerFilterDebounce = Timer(const Duration(milliseconds: 1500), () async {
|
||||
if (mounted) {
|
||||
await maProvider.forceLibrarySync();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Updated Tab Builders
|
||||
|
||||
Artists tab now uses source tracking:
|
||||
|
||||
```dart
|
||||
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
||||
return Selector<MusicAssistantProvider, (List<Artist>, bool, Set<String>)>(
|
||||
selector: (_, provider) => (
|
||||
provider.artists,
|
||||
provider.isLoading,
|
||||
provider.enabledProviderIds.toSet()
|
||||
),
|
||||
builder: (context, data, _) {
|
||||
final (allArtists, isLoading, enabledProviders) = data;
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
final syncService = SyncService.instance;
|
||||
final filteredArtists = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getArtistsFilteredByProviders(enabledProviders)
|
||||
: allArtists;
|
||||
|
||||
// ... rest of build
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Similar updates for albums, playlists, and audiobooks tabs.
|
||||
|
||||
### 5. Provider Instance Model
|
||||
|
||||
**File:** `lib/models/provider_instance.dart` (new file)
|
||||
|
||||
```dart
|
||||
class ProviderInstance {
|
||||
final String instanceId; // e.g., "spotify--abc123"
|
||||
final String domain; // e.g., "spotify"
|
||||
final String name; // e.g., "Spotify (John's Account)"
|
||||
final bool available;
|
||||
|
||||
/// Known music provider domains
|
||||
static const Set<String> musicProviderDomains = {
|
||||
'spotify', 'tidal', 'qobuz', 'deezer', 'ytmusic', 'soundcloud',
|
||||
'apple_music', 'amazon_music', 'plex', 'subsonic', 'opensubsonic',
|
||||
'jellyfin', 'emby', 'navidrome', 'audiobookshelf', 'filesystem',
|
||||
'tunein', 'radiobrowser', 'snapcast', 'fully_kiosk',
|
||||
};
|
||||
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 1. MusicAssistantProvider.toggleProviderEnabled() │
|
||||
│ - Updates enabledProviderIds │
|
||||
│ - Triggers setState() for instant UI update │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 2. UI Rebuilds Immediately │
|
||||
│ - Selector detects enabledProviderIds change │
|
||||
│ - Calls SyncService.getArtistsFilteredByProviders() │
|
||||
│ - Filters using source tracking (instant, no network) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 3. Background Sync (after 1.5s debounce) │
|
||||
│ - forceLibrarySync() called │
|
||||
│ - Syncs each enabled provider separately │
|
||||
│ - Updates source tracking in database │
|
||||
│ - UI refreshes with fresh data │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/database/database.dart` | Added `sourceProviders` column, schema v6, migration |
|
||||
| `lib/database/database.g.dart` | Auto-generated from schema changes |
|
||||
| `lib/services/database_service.dart` | Added `sourceProvider` param, `getCachedItemsWithProviders()` |
|
||||
| `lib/services/sync_service.dart` | Per-provider sync, source tracking maps, filtering methods |
|
||||
| `lib/screens/new_library_screen.dart` | Hybrid toggle handler, updated tab builders |
|
||||
| `lib/models/provider_instance.dart` | New model for provider instances |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Fresh install: First sync should populate source tracking
|
||||
- [ ] Toggle single provider: UI updates instantly
|
||||
- [ ] Toggle multiple providers rapidly: Debounce prevents race conditions
|
||||
- [ ] Two Spotify accounts: Toggling one only shows that account's items
|
||||
- [ ] Mixed providers: Toggling Spotify doesn't affect Audiobookshelf items
|
||||
- [ ] Legacy cache: Items without source tracking show regardless of filter
|
||||
- [ ] Pull to refresh: Refreshes source tracking data
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **First sync required:** Source tracking only works after at least one sync with providers enabled
|
||||
2. **Network cost:** Per-provider sync makes N API calls (one per provider) vs. 1 call for all providers
|
||||
3. **Items in multiple accounts:** If the same item exists in both Spotify accounts, it will show when either is enabled
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Incremental sync:** Only sync changed items instead of full library
|
||||
2. **Provider-specific cache age:** Different sync intervals per provider
|
||||
3. **Offline source tracking:** Persist source tracking independently of full cache
|
||||
|
||||
## Branch
|
||||
|
||||
All changes are on the `feature/per-provider-sync` branch.
|
||||
|
||||
```bash
|
||||
git checkout feature/per-provider-sync
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat: Add per-provider sync with source tracking for instant filtering
|
||||
|
||||
- Add sourceProviders column to database schema (v6) for tracking which
|
||||
provider instance each item came from
|
||||
- Update SyncService to sync each enabled provider separately and tag
|
||||
items with their source provider
|
||||
- Add client-side filtering methods that use source tracking to properly
|
||||
differentiate between multiple accounts of the same provider type
|
||||
- Update library UI to use instant client-side filtering with background
|
||||
sync for data freshness
|
||||
```
|
||||
@@ -0,0 +1,217 @@
|
||||
# Provider Filter Implementation Notes
|
||||
|
||||
## Overview
|
||||
|
||||
This document captures the context and learnings from an attempted implementation of provider filtering in the Ensemble app. The goal was to allow users to filter their library to show only items from a specific music provider (e.g., only one Spotify account when multiple are configured).
|
||||
|
||||
## The Problem
|
||||
|
||||
Users with multiple music provider accounts (e.g., two Spotify accounts: one personal, one family) want to filter their library view to show only items from one specific account. The Music Assistant (MA) web UI successfully implements this feature.
|
||||
|
||||
## What Works vs What Failed
|
||||
|
||||
### Works: Different Provider Types
|
||||
Filtering between different provider types (e.g., Spotify vs Tidal vs Plex) works correctly. Each provider type has distinct `provider_mappings` entries.
|
||||
|
||||
### Failed: Multiple Accounts of Same Provider
|
||||
Filtering between multiple Spotify accounts (e.g., `spotify--abc123` vs `spotify--xyz789`) does NOT work correctly. The same library items appear regardless of which Spotify account is selected.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### The provider_mappings Problem
|
||||
|
||||
MA's `provider_mappings` field on media items does NOT indicate "which account ADDED this item to the library." Instead, it indicates "which providers CAN PLAY this item."
|
||||
|
||||
Example: An artist "Ari Mason" that exists ONLY in User A's Spotify library will show provider_mappings containing BOTH Spotify accounts:
|
||||
```json
|
||||
{
|
||||
"provider_mappings": [
|
||||
{"provider_instance": "spotify--userA_id", "item_id": "..."},
|
||||
{"provider_instance": "spotify--userB_id", "item_id": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This happens because:
|
||||
1. MA's internal database stores that the artist exists
|
||||
2. MA knows both Spotify accounts CAN play this artist (Spotify has the content)
|
||||
3. MA doesn't track WHICH account originally added the item to the library
|
||||
|
||||
### MA Web UI Behavior
|
||||
|
||||
The MA web UI's provider filter works differently - it appears to use server-side filtering via the `provider` parameter on library_items API calls. When you select a single provider in MA settings, the API returns only items from that provider's library.
|
||||
|
||||
## API Investigation
|
||||
|
||||
### Server-Side Filtering Parameter
|
||||
|
||||
The MA server's `library_items` method accepts a `provider` parameter:
|
||||
- Parameter name: `provider` (string or list of strings)
|
||||
- Endpoint: `music/{type}/library_items` (e.g., `music/artists/library_items`)
|
||||
- Expected behavior: Filter to only return items from the specified provider's library
|
||||
|
||||
### What We Tried
|
||||
|
||||
1. **Client-side filtering based on provider_mappings**
|
||||
- Result: Failed - all Spotify accounts appear in mappings regardless of which added the item
|
||||
|
||||
2. **Server-side filtering with `provider` parameter**
|
||||
- Result: Partially implemented but behavior was inconsistent
|
||||
- The API accepts the parameter but may not filter correctly for same-provider-type accounts
|
||||
|
||||
3. **Using `provider_instance_id_or_domain` parameter**
|
||||
- Result: This parameter is for getting specific items by ID, not for filtering library_items
|
||||
|
||||
## Code Locations
|
||||
|
||||
### Files Modified (in stash)
|
||||
|
||||
The implementation attempt is preserved in git stash:
|
||||
```bash
|
||||
git stash list # Look for "provider-filter-and-dropdown-work"
|
||||
git stash show -p stash@{0} # View the changes
|
||||
```
|
||||
|
||||
Key files that were modified:
|
||||
|
||||
1. **lib/providers/music_assistant_provider.dart**
|
||||
- `_disabledProviders`: List of disabled provider instance IDs (local app setting)
|
||||
- `selectedProviderForFilter`: Returns single enabled provider for server-side filtering
|
||||
- `toggleProviderEnabled()`: Toggle provider on/off and trigger re-sync
|
||||
- `loadDisabledProviders()`: Load from local settings
|
||||
- `filterByProvider()`: Client-side filtering (enhanced version)
|
||||
- `isItemAllowedByProviderFilter()`: Check if item should be visible
|
||||
|
||||
2. **lib/services/music_assistant_api.dart**
|
||||
- Added `provider` parameter to: `getArtists`, `getAlbums`, `getTracks`, `getRandomArtists`, `getRandomAlbums`
|
||||
- Added `MusicProvider` model parsing from `/providers` endpoint
|
||||
- `musicProviders` getter: List of available music providers
|
||||
|
||||
3. **lib/services/sync_service.dart**
|
||||
- Added `provider` parameter to `syncFromApi()` and `forceSync()`
|
||||
- Pass provider filter to API calls during sync
|
||||
|
||||
4. **lib/services/settings_service.dart**
|
||||
- `getDisabledMusicProviders()`: Get list of disabled provider IDs
|
||||
- `setDisabledMusicProviders()`: Save disabled providers
|
||||
- `toggleProviderEnabled()`: Toggle and persist
|
||||
|
||||
5. **lib/screens/settings_screen.dart**
|
||||
- UI for toggling individual providers on/off
|
||||
- Display provider name and instance ID
|
||||
|
||||
6. **lib/screens/new_library_screen.dart**
|
||||
- Integration with provider filtering
|
||||
|
||||
7. **lib/models/music_provider.dart** (new file)
|
||||
- Model for MA provider instances
|
||||
- Fields: `instanceId`, `domain`, `name`, `available`
|
||||
|
||||
## Race Condition Issue
|
||||
|
||||
A race condition was identified where:
|
||||
1. Initial app sync starts WITHOUT provider filter
|
||||
2. User toggles provider filter
|
||||
3. `clearCache()` is called
|
||||
4. New sync with provider filter tries to start
|
||||
5. "Sync already in progress" - new sync is skipped
|
||||
6. Result: Old unfiltered data remains
|
||||
|
||||
Fix attempted: Reset `_isSyncing = false` in `clearCache()` to allow fresh sync.
|
||||
|
||||
## Potential Solutions to Explore
|
||||
|
||||
### 1. Investigate MA Server-Side Filtering
|
||||
|
||||
The MA server should support filtering by provider. Need to verify:
|
||||
- Does the `provider` parameter work for filtering between same-type providers?
|
||||
- Is there a different parameter or API endpoint?
|
||||
- Check MA source code: https://github.com/music-assistant/server
|
||||
|
||||
### 2. Library Sync Per Provider
|
||||
|
||||
Instead of syncing all items and filtering, sync each provider's library separately:
|
||||
```dart
|
||||
for (final provider in enabledProviders) {
|
||||
final items = await api.getAlbums(provider: provider.instanceId);
|
||||
// Store with provider association
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Track Item Origin
|
||||
|
||||
Store which provider originally added each item:
|
||||
- Modify database schema to track `source_provider`
|
||||
- When syncing, record which provider the item came from
|
||||
- Filter based on source, not playability
|
||||
|
||||
### 4. Use MA's Internal Library State
|
||||
|
||||
MA might have internal state about which items are in which provider's library. Investigate:
|
||||
- `/music/library/items` endpoint
|
||||
- Provider-specific library endpoints
|
||||
- MA's database schema for library associations
|
||||
|
||||
### 5. Check MA User Settings
|
||||
|
||||
The MA web UI's provider filter might be stored in user settings:
|
||||
```dart
|
||||
final userInfo = await api.getCurrentUserInfo();
|
||||
// Check for provider_filter or similar field
|
||||
```
|
||||
|
||||
## Testing Notes
|
||||
|
||||
### Log Messages to Watch
|
||||
|
||||
```
|
||||
🔌 Selected provider for filter: spotify--xxxxx
|
||||
🔍 API getArtists args: {limit: 1000, provider: spotify--xxxxx}
|
||||
🔍 API getArtists returned XXX items
|
||||
🔌 filterByProvider: XXX → YYY (disabled=[...])
|
||||
```
|
||||
|
||||
### Test Scenario
|
||||
|
||||
1. Configure two Spotify accounts in MA
|
||||
2. Add different artists to each account's library
|
||||
3. In Ensemble, select only one Spotify account
|
||||
4. Verify library shows ONLY that account's items
|
||||
5. Switch to other account, verify different items
|
||||
|
||||
### Expected vs Actual
|
||||
|
||||
- **Expected**: Artist "Ari Mason" (only in Account A) should NOT appear when Account B is selected
|
||||
- **Actual**: Artist appears regardless of which account is selected
|
||||
|
||||
## MA API Reference
|
||||
|
||||
### Get Providers
|
||||
```
|
||||
Command: providers
|
||||
Returns: List of provider instances with type, domain, instanceId, name, available
|
||||
```
|
||||
|
||||
### Library Items with Filter
|
||||
```
|
||||
Command: music/artists/library_items
|
||||
Args: {
|
||||
limit: int,
|
||||
offset: int,
|
||||
provider: string | list<string>, // Filter to specific provider(s)
|
||||
album_artists_only: bool,
|
||||
order_by: string
|
||||
}
|
||||
```
|
||||
|
||||
### Relevant MA Source Files
|
||||
|
||||
- `music_assistant/server/controllers/music.py` - Library item retrieval
|
||||
- `music_assistant/server/models/provider.py` - Provider model
|
||||
- `music_assistant/server/providers/` - Individual provider implementations
|
||||
|
||||
## Conclusion
|
||||
|
||||
The core challenge is that MA's `provider_mappings` indicates playability, not ownership. Server-side filtering via the `provider` parameter should work but needs further investigation to understand why it doesn't differentiate between multiple accounts of the same provider type.
|
||||
|
||||
The implementation is preserved in the git stash for future reference. The sorting/dropdown improvements were extracted and committed separately.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Audiobook Provider Filtering Fix
|
||||
|
||||
## Date: January 17, 2026 (Continuation)
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering issues discovered after the initial provider filtering improvements:
|
||||
1. Audiobooks were not responding to provider filter changes (browse API ignoring `providerInstanceIds`)
|
||||
2. UI not updating instantly when toggling providers (missing `notifyListeners()` call)
|
||||
3. Audiobook tabs not rebuilding on provider changes (using `context.read` instead of `Selector`)
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
After implementing capability-based provider filtering for the music library, two issues remained:
|
||||
|
||||
### Issue 1: Audiobooks Not Filtering During Sync
|
||||
|
||||
The bug report showed:
|
||||
```
|
||||
📊 Source tracking: 252 albums, 87 artists have provider info
|
||||
📚 Total audiobooks from browse: 186
|
||||
```
|
||||
|
||||
- Albums and artists had provider source tracking
|
||||
- Audiobooks had NO provider source tracking (not listed)
|
||||
- All 186 audiobooks were returned regardless of provider selection
|
||||
|
||||
### Issue 2: UI Not Updating Instantly
|
||||
|
||||
When toggling providers in the library menu:
|
||||
- Music tabs weren't updating immediately
|
||||
- Audiobooks required manual pull-to-refresh
|
||||
- Changes only appeared after ~600ms (debounce timer)
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Cause 1: Browse API Ignoring Provider Filter
|
||||
|
||||
In `lib/services/music_assistant_api.dart`, when ABS library filtering is enabled, the `getAudiobooks()` function uses the browse API but was **ignoring** the `providerInstanceIds` parameter:
|
||||
|
||||
```dart
|
||||
// BEFORE: providerInstanceIds was ignored when using browse API
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Cause 2: `notifyListeners()` Called Too Late
|
||||
|
||||
In `lib/providers/music_assistant_provider.dart`, the `toggleProviderEnabled()` function only called `notifyListeners()` **inside** the 600ms debounce timer:
|
||||
|
||||
```dart
|
||||
// BEFORE: notifyListeners only called after 600ms debounce
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
forceLibrarySync();
|
||||
notifyListeners(); // Too late! UI should update immediately
|
||||
});
|
||||
```
|
||||
|
||||
### Cause 3: Audiobook Tabs Not Listening for Changes
|
||||
|
||||
The audiobook tabs used `context.read<MusicAssistantProvider>()` which doesn't trigger rebuilds when the provider changes:
|
||||
|
||||
```dart
|
||||
// BEFORE: context.read doesn't listen for changes
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
// ... widget doesn't rebuild when enabledProviders changes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solutions
|
||||
|
||||
### Fix 1: Browse API Provider Filtering
|
||||
|
||||
**File:** `lib/services/music_assistant_api.dart` (lines 760-785)
|
||||
|
||||
Added logic to filter library paths by provider ID before browsing:
|
||||
|
||||
```dart
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
_logger.log('📚 getAudiobooks: enabledLibraries=$enabledLibraries, providerInstanceIds=$providerInstanceIds');
|
||||
|
||||
// If specific providers are requested, filter libraries to only matching providers
|
||||
// Library paths start with provider ID (e.g., "audiobookshelf--abc123://lb...")
|
||||
var librariesToBrowse = enabledLibraries;
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
librariesToBrowse = enabledLibraries.where((libPath) {
|
||||
// Extract provider ID from library path (everything before "://")
|
||||
final providerEnd = libPath.indexOf('://');
|
||||
if (providerEnd == -1) return false;
|
||||
final providerId = libPath.substring(0, providerEnd);
|
||||
return providerInstanceIds.contains(providerId);
|
||||
}).toList();
|
||||
|
||||
// If no libraries match the requested providers, return empty
|
||||
if (librariesToBrowse.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 2: Immediate `notifyListeners()` Call
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart` (line 1149)
|
||||
|
||||
Moved `notifyListeners()` to fire immediately after `_enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
// Reload the enabled providers
|
||||
final savedEnabled = await SettingsService.getEnabledMusicProviders();
|
||||
_enabledProviderIds = savedEnabled ?? [];
|
||||
|
||||
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
|
||||
// This enables instant UI updates using cached data with source tracking
|
||||
notifyListeners();
|
||||
|
||||
// Cancel any pending debounce timer
|
||||
_providerFilterDebounceTimer?.cancel();
|
||||
|
||||
// Start debounced sync - waits 600ms for user to finish toggling
|
||||
// This refreshes the cache with new data from enabled providers
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
_logger.log('🔄 Debounce complete, starting library sync...');
|
||||
forceLibrarySync();
|
||||
});
|
||||
```
|
||||
|
||||
### Fix 3: Audiobook Tabs Use Selector
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Wrapped `_buildBooksAuthorsTab` and `_buildAllBooksTab` in `Selector` to rebuild when `enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
// Use Selector to rebuild when enabledProviderIds changes
|
||||
return Selector<MusicAssistantProvider, Set<String>>(
|
||||
selector: (_, provider) => provider.enabledProviderIds.toSet(),
|
||||
builder: (context, enabledProviders, _) {
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
// Client-side filtering using SyncService source tracking for instant updates
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// ... rest of widget
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 4: Enhanced Sync Logging
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
Added audiobook counts to logging:
|
||||
|
||||
```dart
|
||||
// Per-provider log (line 237)
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||
|
||||
// Source tracking summary (line 323)
|
||||
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow After Fixes
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
│ │
|
||||
│ 1. toggleProviderEnabled() called │
|
||||
│ 2. _enabledProviderIds updated │
|
||||
│ 3. notifyListeners() called IMMEDIATELY │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Selectors Detect Change │
|
||||
│ │
|
||||
│ Music tabs: Selector<..., (List, bool, Set<String>)> │
|
||||
│ Audiobook tabs: Selector<..., Set<String>> │
|
||||
│ │
|
||||
│ → All tabs rebuild immediately │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Client-Side Filtering (Instant) │
|
||||
│ │
|
||||
│ syncService.getAlbumsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getArtistsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getAudiobooksFilteredByProviders(enabledProviders)│
|
||||
│ │
|
||||
│ → Uses EXISTING cache with source tracking │
|
||||
│ → Instant UI update, no network required │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Background Sync (After 600ms Debounce) │
|
||||
│ │
|
||||
│ - Refreshes cache with new data from enabled providers │
|
||||
│ - Updates source tracking │
|
||||
│ - Only needed when ENABLING providers (to fetch new data) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/services/music_assistant_api.dart` | Added provider filtering to browse API path in `getAudiobooks()`, added debug logging |
|
||||
| `lib/services/sync_service.dart` | Added audiobook counts to per-provider and summary logs |
|
||||
| `lib/providers/music_assistant_provider.dart` | Moved `notifyListeners()` to fire immediately on provider toggle |
|
||||
| `lib/screens/new_library_screen.dart` | Wrapped audiobook tabs in `Selector` to rebuild on provider changes |
|
||||
|
||||
---
|
||||
|
||||
## Expected Behavior After Fixes
|
||||
|
||||
1. **Instant UI Updates:** When you toggle a provider, the library immediately updates using client-side filtering
|
||||
2. **Audiobook Source Tracking:** Audiobooks now have provider source tracking (visible in logs)
|
||||
3. **Audiobook Tab Rebuilds:** Both audiobook tabs (Authors and All Books) rebuild instantly on provider toggle
|
||||
4. **Music Tab Consistency:** Music tabs continue to work as before, now with immediate rebuilds
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Toggle a music provider, verify instant UI update (no 600ms delay)
|
||||
- [ ] Toggle between different Spotify accounts, verify correct filtering
|
||||
- [ ] Sync with ABS library filtering enabled
|
||||
- [ ] Verify audiobooks appear in source tracking log
|
||||
- [ ] Toggle ABS provider off in library menu
|
||||
- [ ] Verify audiobooks list becomes empty immediately (no pull-to-refresh needed)
|
||||
- [ ] Toggle ABS provider back on
|
||||
- [ ] Verify audiobooks reappear after sync completes
|
||||
- [ ] Test with multiple providers (Spotify audiobooks + ABS)
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/SESSION_2026_01_17_PROVIDER_FILTERING.md` - Initial provider filtering implementation
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Per-provider sync architecture
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab still doesn't filter by provider. Series are fetched as `AudiobookSeries` objects without per-provider tracking.
|
||||
|
||||
2. **Mixed Provider Scenario:** If user has both Spotify audiobooks and ABS audiobooks, and ABS library filtering is enabled, Spotify audiobooks may not be fetched because the browse API path is taken. This is a pre-existing architectural limitation.
|
||||
|
||||
3. **Enabling Providers:** When ENABLING a previously disabled provider, the UI updates immediately but shows empty until the background sync completes (because the cache doesn't have that provider's data yet). This is expected behavior.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Session 2026-01-17: Optimistic Updates & Podcast Provider Filtering
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering for podcasts, resolving iTunes podcast provider issues, and implementing optimistic updates for library buttons across detail screens.
|
||||
|
||||
## Issues Addressed
|
||||
|
||||
### 1. Podcast Provider Counts Showing Incorrectly
|
||||
|
||||
**Problem**: All Spotify accounts showed the same podcast count instead of only counting podcasts owned by each account.
|
||||
|
||||
**Root Cause**: The podcast counting logic wasn't checking the `inLibrary` field on provider mappings.
|
||||
|
||||
**Solution**:
|
||||
- Added `inLibrary` field to `ProviderMapping` model
|
||||
- Updated `getProvidersWithPodcasts()` to only count mappings where `inLibrary == true`
|
||||
- Updated podcast filtering in library screen to check `inLibrary` field
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/media_item.dart` - Added `inLibrary` field
|
||||
- `lib/providers/music_assistant_provider.dart` - Updated counting logic
|
||||
- `lib/screens/new_library_screen.dart` - Updated filtering logic
|
||||
|
||||
### 2. iTunes Podcasts Not Appearing in Library
|
||||
|
||||
**Problem**: Podcasts added from iTunes search weren't showing in library and iTunes provider filter showed 0 count.
|
||||
|
||||
**Investigation**:
|
||||
- Added `itunes_podcasts` to provider capabilities and music provider domains
|
||||
- Added synthetic provider instance creation for search-only providers
|
||||
- Added debug logging to understand podcast loading
|
||||
|
||||
**Resolution**: The issue was with Music Assistant's iTunes podcast provider itself - podcasts added via iTunes weren't being properly stored in the MA library. The user removed the iTunes provider from MA and re-added podcasts via Spotify search as a workaround.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/provider_instance.dart` - Added `itunes_podcasts` to capabilities
|
||||
|
||||
### 3. Episode Loading Failing for Non-Library Podcasts
|
||||
|
||||
**Problem**: When a podcast was removed from library, the old library ID became invalid and episodes wouldn't load.
|
||||
|
||||
**Solution**: Added fallback logic to try loading episodes via provider mappings when the primary ID fails.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart` - Added fallback episode loading
|
||||
|
||||
### 4. Search Filtering Rework
|
||||
|
||||
**Problem**: Server-side provider filtering was being applied to search results when it should only apply when the "in library" toggle is enabled.
|
||||
|
||||
**Solution**:
|
||||
- Removed `filterSearchResults()` calls from search methods
|
||||
- Added client-side `_filterByEnabledProviders()` helper
|
||||
- Applied filtering only when `_libraryOnly` toggle is ON
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/providers/music_assistant_provider.dart` - Removed filterSearchResults calls
|
||||
- `lib/screens/search_screen.dart` - Added client-side filtering
|
||||
|
||||
### 5. Library Buttons Not Updating Immediately
|
||||
|
||||
**Problem**: Add/remove library buttons on detail screens waited for API response before updating, causing perceived lag.
|
||||
|
||||
**Solution**: Implemented optimistic updates - update UI immediately, revert on failure.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart`
|
||||
- `lib/screens/album_details_screen.dart`
|
||||
- `lib/screens/artist_details_screen.dart`
|
||||
|
||||
## Code Changes
|
||||
|
||||
### ProviderMapping.inLibrary Field
|
||||
|
||||
```dart
|
||||
// lib/models/media_item.dart
|
||||
class ProviderMapping {
|
||||
final String itemId;
|
||||
final String providerDomain;
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
final bool inLibrary; // NEW: indicates ownership
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderMapping(
|
||||
// ... other fields ...
|
||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Podcast Provider Counting
|
||||
|
||||
```dart
|
||||
// lib/providers/music_assistant_provider.dart
|
||||
Map<String, int> getProvidersWithPodcasts() {
|
||||
final counts = <String, int>{};
|
||||
for (final item in _podcasts) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
```
|
||||
|
||||
### Optimistic Update Pattern
|
||||
|
||||
```dart
|
||||
// Example from podcast_detail_screen.dart
|
||||
Future<void> _toggleLibrary() async {
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.addedToLibrary)),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(...).catchError((e) {
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side Search Filtering
|
||||
|
||||
```dart
|
||||
// lib/screens/search_screen.dart
|
||||
Map<String, List<MediaItem>> _filterByEnabledProviders(
|
||||
Map<String, List<MediaItem>> results,
|
||||
Set<String> enabledProviders,
|
||||
) {
|
||||
if (enabledProviders.isEmpty) return results;
|
||||
|
||||
bool isItemAllowed(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings == null || mappings.isEmpty) return true;
|
||||
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
|
||||
}
|
||||
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowed).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
// Applied when setting results:
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Capabilities Update
|
||||
|
||||
```dart
|
||||
// lib/models/provider_instance.dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'}, // NEW
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
// ... other providers
|
||||
};
|
||||
```
|
||||
|
||||
## Summary of All Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/media_item.dart` | Added `inLibrary` field to ProviderMapping |
|
||||
| `lib/models/provider_instance.dart` | Added `itunes_podcasts`, podcasts to capabilities |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated podcast counting, removed filterSearchResults |
|
||||
| `lib/screens/new_library_screen.dart` | Updated podcast filtering with inLibrary check |
|
||||
| `lib/screens/search_screen.dart` | Added client-side filtering for in-library mode |
|
||||
| `lib/screens/podcast_detail_screen.dart` | Optimistic updates, fallback episode loading |
|
||||
| `lib/screens/album_details_screen.dart` | Optimistic updates for library button |
|
||||
| `lib/screens/artist_details_screen.dart` | Optimistic updates for library button |
|
||||
|
||||
## Key Learnings
|
||||
|
||||
1. **inLibrary field**: Music Assistant's provider mappings include an `in_library` field that indicates whether the user added the item from that specific provider account. This is essential for accurate per-provider filtering.
|
||||
|
||||
2. **iTunes podcast provider**: The MA iTunes podcast provider is for search only - it doesn't properly store items in the library. Users should add podcasts from streaming providers like Spotify instead.
|
||||
|
||||
3. **Optimistic updates**: For better UX, library toggle buttons should update immediately and only revert if the API call fails, rather than waiting for API response.
|
||||
|
||||
4. **Search vs Library filtering**: Server-side provider filtering should only apply to library views, not search results (unless the "in library" toggle is enabled).
|
||||
@@ -0,0 +1,263 @@
|
||||
# Provider Filtering Improvements
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on improving the provider filtering system across the app, building on the per-provider sync implementation. Key improvements include a cleaner capability-based architecture, fixing filter visibility bugs, and extending filtering to the audiobooks library.
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Provider Capability Mapping
|
||||
|
||||
**File:** `lib/models/provider_instance.dart`
|
||||
|
||||
Replaced the clunky item-count-based filtering with a static capability map that defines what content types each provider domain supports.
|
||||
|
||||
```dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Self-hosted media servers (can have music and audiobooks)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Audiobook-only providers
|
||||
'audiobookshelf': {'audiobooks'},
|
||||
|
||||
// Radio providers
|
||||
'tunein': {'radio'},
|
||||
'radiobrowser': {'radio'},
|
||||
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
```
|
||||
|
||||
**Added helper methods:**
|
||||
```dart
|
||||
Set<String> get supportedContentTypes => providerCapabilities[domain] ?? <String>{};
|
||||
bool supportsContentType(String category) => supportedContentTypes.contains(category);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Radio providers never show in Artists tab (by design, not by count)
|
||||
- Works immediately, even with empty libraries
|
||||
- Disabled providers still show in menu (if they support the content type)
|
||||
- Clean separation of concerns
|
||||
|
||||
---
|
||||
|
||||
### 2. Updated Provider Filtering in MusicAssistantProvider
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart`
|
||||
|
||||
Updated `getRelevantProvidersForCategory()` to filter by capability:
|
||||
|
||||
```dart
|
||||
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
|
||||
// ... get counts ...
|
||||
|
||||
final result = <(ProviderInstance, int)>[];
|
||||
for (final provider in _availableMusicProviders) {
|
||||
// Only include providers that support this content type
|
||||
if (provider.supportsContentType(category)) {
|
||||
final count = counts[provider.instanceId] ?? 0;
|
||||
result.add((provider, count));
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Simplified Library Menu Provider Filter
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Simplified the options menu overlay to show all providers that support the current category:
|
||||
|
||||
**Before (clunky):**
|
||||
```dart
|
||||
if (widget.relevantProviders.where((p) {
|
||||
final hasItems = p.$2 > 0;
|
||||
final isDisabledByUser = widget.enabledProviderIds.isNotEmpty &&
|
||||
!widget.enabledProviderIds.contains(p.$1.instanceId);
|
||||
return hasItems || isDisabledByUser;
|
||||
}).length > 1) ...[
|
||||
```
|
||||
|
||||
**After (clean):**
|
||||
```dart
|
||||
// relevantProviders is pre-filtered by capability
|
||||
if (widget.relevantProviders.length > 1) ...[
|
||||
// Show all providers that support this category
|
||||
...widget.relevantProviders.map((providerData) {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Removed Provider Toggles from Settings Screen
|
||||
|
||||
**File:** `lib/screens/settings_screen.dart`
|
||||
|
||||
Removed the "Music Providers" section entirely since provider filtering is now handled in the library menu:
|
||||
|
||||
- Removed `_providerEnabled` state variable
|
||||
- Removed provider loading code from `_loadSettings()`
|
||||
- Removed the entire Music Providers UI section (~100 lines)
|
||||
|
||||
**Rationale:** Single source of truth - provider toggles now only appear in the library menu where they're contextually relevant.
|
||||
|
||||
---
|
||||
|
||||
### 5. Fixed Audiobooks Library Provider Filtering
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
The audiobooks tabs were not filtering by provider - they loaded data once and displayed it without responding to provider changes.
|
||||
|
||||
**Fixed `_buildBooksAuthorsTab()`:**
|
||||
```dart
|
||||
final syncService = SyncService.instance;
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// Filter by favorites if enabled
|
||||
if (_showFavoritesOnly) {
|
||||
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
|
||||
}
|
||||
|
||||
// Group filtered audiobooks by author (dynamic, not cached)
|
||||
final groupedByAuthor = <String, List<Audiobook>>{};
|
||||
for (final book in audiobooks) {
|
||||
final authorName = book.authorsString;
|
||||
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
|
||||
}
|
||||
```
|
||||
|
||||
**Fixed `_buildAllBooksTab()`:**
|
||||
- Same provider filtering pattern
|
||||
- Dynamic sorting after filtering
|
||||
- Updated PageStorageKey to include provider count for proper rebuild
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Provider Filter Menu Disappearing
|
||||
|
||||
**Problem:** When providers were deselected, the background sync would clear the cache and only fetch data for enabled providers. Disabled providers then had 0 items, and the menu required `> 1 providers WITH items` to show. The entire Providers section would disappear.
|
||||
|
||||
**Solution:** Changed to capability-based filtering. The menu now shows all providers that support the category, regardless of item count.
|
||||
|
||||
### Audiobooks Not Filtering by Provider
|
||||
|
||||
**Problem:** Audiobooks tabs loaded data once into `_audiobooks` and displayed it without provider filtering. Toggling providers in the menu had no effect.
|
||||
|
||||
**Solution:** Added inline provider filtering during build (same pattern as music tabs).
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/provider_instance.dart` | Added `providerCapabilities` map and helper methods |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated `getRelevantProvidersForCategory()` to filter by capability |
|
||||
| `lib/screens/new_library_screen.dart` | Simplified menu filter logic, added audiobook provider filtering |
|
||||
| `lib/screens/settings_screen.dart` | Removed Music Providers section |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Provider Capability Map │
|
||||
│ (ProviderInstance.providerCapabilities) │
|
||||
│ │
|
||||
│ spotify → {artists, albums, tracks, playlists, audiobooks} │
|
||||
│ audiobookshelf → {audiobooks} │
|
||||
│ tunein → {radio} │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ getRelevantProvidersForCategory(category) │
|
||||
│ │
|
||||
│ - Filters _availableMusicProviders by capability │
|
||||
│ - Returns only providers that support the category │
|
||||
│ - Includes item counts for display │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Library Menu (Options) │
|
||||
│ │
|
||||
│ - Shows providers section if > 1 providers support category │
|
||||
│ - All relevant providers shown (can toggle on/off) │
|
||||
│ - Instant UI update via setState() │
|
||||
│ - Background sync after 1.5s debounce │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Tab Build Methods │
|
||||
│ │
|
||||
│ - Get enabledProviderIds from MusicAssistantProvider │
|
||||
│ - Filter data using SyncService source tracking │
|
||||
│ - Display filtered results │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab in audiobooks doesn't filter by provider yet. Series are fetched separately as `AudiobookSeries` objects and don't have the same source tracking as individual audiobooks.
|
||||
|
||||
2. **First Sync Required:** Source tracking only works after at least one sync with providers enabled.
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Music tabs filter by provider correctly
|
||||
- [x] Provider menu shows only relevant providers per category
|
||||
- [x] Radio providers don't appear in Artists/Albums tabs
|
||||
- [x] Spotify appears in Audiobooks tab (has audiobooks)
|
||||
- [x] Audiobooks Authors tab filters by provider
|
||||
- [x] Audiobooks All Books tab filters by provider
|
||||
- [ ] Audiobooks Series tab filters by provider (not implemented)
|
||||
- [x] Disabled providers can be re-enabled from menu
|
||||
- [x] Settings screen no longer has provider toggles
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Original per-provider sync implementation
|
||||
@@ -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
|
||||
@@ -0,0 +1,221 @@
|
||||
# Feature: Replace "Play On" Popups with Player Selector
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the `PlayerPickerSheet` popup modal with the existing Player Selector (reveal overlay) when users tap "Play on" buttons throughout the app. This provides a consistent, richer player selection experience.
|
||||
|
||||
## Current State
|
||||
|
||||
### PlayerPickerSheet Popup
|
||||
Used in 7+ places:
|
||||
- `album_details_screen.dart` - Play Album, Play From Here, Play Radio
|
||||
- `playlist_details_screen.dart` - Play Playlist, Play Radio
|
||||
- `search_screen.dart` - Play album/playlist/audiobook/radio from search
|
||||
- `podcast_detail_screen.dart` - Play Episode
|
||||
- `audiobook_detail_screen.dart` - Play Audiobook
|
||||
- `queue_screen.dart` - Transfer Queue (special case - keep popup)
|
||||
|
||||
### Player Selector (Reveal Overlay)
|
||||
- Location: `lib/widgets/player/player_reveal_overlay.dart`
|
||||
- Shows vertical list of all players with artwork, current track, sync status
|
||||
- Has hints above player list: "Long-press to sync" & "Swipe to adjust volume"
|
||||
- Triggered by pull-down gesture or device button tap
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Modify PlayerRevealOverlay
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart`
|
||||
|
||||
Add new optional parameters:
|
||||
|
||||
```dart
|
||||
class PlayerRevealOverlay extends StatefulWidget {
|
||||
final VoidCallback onDismiss;
|
||||
final double miniPlayerBottom;
|
||||
final double miniPlayerHeight;
|
||||
final bool showOnboardingHints;
|
||||
|
||||
// NEW: Callback when player is selected for a pending action
|
||||
final void Function(Player player)? onPlayerSelected;
|
||||
|
||||
// NEW: Context hint to display instead of default hints
|
||||
// e.g., "Select player to play album"
|
||||
final String? contextHint;
|
||||
|
||||
// NEW: Icon for context hint (default: speaker icon)
|
||||
final IconData? contextHintIcon;
|
||||
```
|
||||
|
||||
### 2. Modify Hint Display
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 386-394)
|
||||
|
||||
```dart
|
||||
// Current:
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
|
||||
// New:
|
||||
: widget.contextHint != null
|
||||
? buildHintRow(
|
||||
widget.contextHintIcon ?? Icons.play_circle_outline,
|
||||
widget.contextHint!,
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
### 3. Modify Player Card onTap
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 437-441 and 490-494)
|
||||
|
||||
```dart
|
||||
// Current:
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(data.player);
|
||||
dismiss();
|
||||
},
|
||||
|
||||
// New:
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
if (widget.onPlayerSelected != null) {
|
||||
// Execute the pending action with selected player
|
||||
widget.onPlayerSelected!(data.player);
|
||||
} else {
|
||||
// Default behavior: just select the player
|
||||
maProvider.selectPlayer(data.player);
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
```
|
||||
|
||||
### 4. Add Helper Method to GlobalPlayerOverlay
|
||||
|
||||
**File:** `lib/widgets/global_player_overlay.dart`
|
||||
|
||||
Add a static method to show player selector with pending action:
|
||||
|
||||
```dart
|
||||
/// Show player selector for a "Play on" action
|
||||
///
|
||||
/// [contextHint] - Message shown instead of default hints
|
||||
/// [onPlayerSelected] - Called when user taps a player
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
/// contextHint: "Select player to play album",
|
||||
/// onPlayerSelected: (player) {
|
||||
/// provider.playAlbum(albumId, playerId: player.playerId);
|
||||
/// },
|
||||
/// );
|
||||
/// ```
|
||||
static void showPlayerSelectorForAction({
|
||||
required String contextHint,
|
||||
required void Function(Player player) onPlayerSelected,
|
||||
IconData? hintIcon,
|
||||
}) {
|
||||
// Implementation: trigger the player reveal overlay with these parameters
|
||||
// May need to store pending action in state and pass to overlay
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Update Calling Sites
|
||||
|
||||
Replace `showPlayerPickerSheet()` calls with new method:
|
||||
|
||||
**Example - album_details_screen.dart:**
|
||||
|
||||
```dart
|
||||
// Current (around line 1305-1316):
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: 'Play Album',
|
||||
onPlayerSelected: (player) async {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
await maProvider.playAlbum(albumId, playerId: player.playerId);
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
},
|
||||
);
|
||||
|
||||
// New:
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum, // Add localization
|
||||
onPlayerSelected: (player) async {
|
||||
await maProvider.playAlbum(albumId, playerId: player.playerId);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### 6. Add Localizations
|
||||
|
||||
**File:** `lib/l10n/app_en.arb` (and other language files)
|
||||
|
||||
```json
|
||||
"selectPlayerToPlay": "Select player to play",
|
||||
"selectPlayerToPlayAlbum": "Select player to play album",
|
||||
"selectPlayerToPlayPlaylist": "Select player to play playlist",
|
||||
"selectPlayerToPlayRadio": "Select player to start radio",
|
||||
"selectPlayerToPlayEpisode": "Select player to play episode",
|
||||
"selectPlayerToPlayAudiobook": "Select player to play audiobook"
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `lib/widgets/player/player_reveal_overlay.dart` - Add parameters, modify hints & onTap
|
||||
2. `lib/widgets/global_player_overlay.dart` - Add helper method
|
||||
3. `lib/screens/album_details_screen.dart` - Replace 3 popup calls
|
||||
4. `lib/screens/playlist_details_screen.dart` - Replace 2 popup calls
|
||||
5. `lib/screens/search_screen.dart` - Replace ~5 popup calls
|
||||
6. `lib/screens/podcast_detail_screen.dart` - Replace 1 popup call
|
||||
7. `lib/screens/audiobook_detail_screen.dart` - Replace 1 popup call
|
||||
8. `lib/l10n/app_en.arb` - Add new strings
|
||||
9. `lib/l10n/app_de.arb`, `app_fr.arb`, `app_es.arb` - Translations
|
||||
|
||||
## Exception: Queue Transfer
|
||||
|
||||
**Keep the popup for `queue_screen.dart`** because:
|
||||
- Needs to filter out current player (can't transfer to self)
|
||||
- Needs to filter out certain player types
|
||||
- Different UX intent (transfer vs play)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Modify `PlayerRevealOverlay` to accept new parameters
|
||||
2. Add helper method to `GlobalPlayerOverlay`
|
||||
3. Test with one screen (album_details_screen.dart)
|
||||
4. Add localizations
|
||||
5. Migrate remaining screens
|
||||
6. Clean up unused code from PlayerPickerSheet if no longer needed elsewhere
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Album details: Play Album button
|
||||
- [ ] Album details: Play From Here (track context menu)
|
||||
- [ ] Album details: Play Radio (track context menu)
|
||||
- [ ] Playlist details: Play Playlist button
|
||||
- [ ] Playlist details: Play Radio (track context menu)
|
||||
- [ ] Search: Play album from results
|
||||
- [ ] Search: Play playlist from results
|
||||
- [ ] Search: Play radio from results
|
||||
- [ ] Podcast detail: Play episode
|
||||
- [ ] Audiobook detail: Play audiobook
|
||||
- [ ] Queue transfer still works with popup
|
||||
- [ ] Long-press to sync still works in player selector
|
||||
- [ ] Dismiss by tapping backdrop works
|
||||
- [ ] Dismiss by swiping down works
|
||||
@@ -0,0 +1,328 @@
|
||||
# Remote Connection - Future Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides guidance for implementing remote connection to Music Assistant servers via WebRTC. Remote connection allows the Ensemble app to connect to MA servers outside the local network using Nabu Casa's WebRTC signaling infrastructure.
|
||||
|
||||
## Current Status: On Hold
|
||||
|
||||
Remote connection was partially implemented but put on hold due to a critical bug where the `ma-api` data channel becomes one-way after 1-3 minutes. See `MA_WEBRTC_BUG_REPORT.md` for details.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌──────────────────────────┐
|
||||
│ Ensemble App │◄──────────────────►│ MA Server (local WS) │
|
||||
└─────────────────┘ └──────────────────────────┘
|
||||
│ │
|
||||
│ (when local unavailable) │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────────┐
|
||||
│ SignalingClient│◄──── WebSocket ───►│ signaling.music-assistant│
|
||||
└─────────────────┘ │ .io/ws │
|
||||
│ └──────────────────────────┘
|
||||
│ ICE candidates, SDP │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────────┐
|
||||
│ WebRTC Peer │◄═══ Data Channel ══►│ MA Server WebRTC │
|
||||
│ Connection │ (ma-api) │ Endpoint │
|
||||
└─────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
1. **SignalingClient** - WebSocket connection to `wss://signaling.music-assistant.io/ws`
|
||||
2. **WebRTCConnection** - Manages RTCPeerConnection and data channels
|
||||
3. **RemoteBridge** - Bridges local WebSocket API to remote WebRTC connection
|
||||
|
||||
### Data Channels
|
||||
|
||||
| Channel | Purpose | Traffic Pattern |
|
||||
|---------|---------|-----------------|
|
||||
| `ma-api` | JSON API requests/responses | High volume, large payloads |
|
||||
| `sendspin` | Audio streaming + control | Low JSON, binary audio data |
|
||||
|
||||
---
|
||||
|
||||
## Key Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/remote/signaling_client.dart` | WebSocket client for signaling server |
|
||||
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection management |
|
||||
| `lib/services/remote/remote_bridge.dart` | Bridges API calls to WebRTC |
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/services/music_assistant_api.dart` | Add remote connection fallback |
|
||||
| `lib/providers/music_assistant_provider.dart` | Expose remote connection state |
|
||||
| `pubspec.yaml` | Add `flutter_webrtc` dependency |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add Dependencies
|
||||
|
||||
```yaml
|
||||
# pubspec.yaml
|
||||
dependencies:
|
||||
flutter_webrtc: ^0.12.0 # or latest
|
||||
```
|
||||
|
||||
### Step 2: Implement SignalingClient
|
||||
|
||||
The signaling client connects to Music Assistant's signaling server and exchanges WebRTC offers/answers.
|
||||
|
||||
```dart
|
||||
class SignalingClient {
|
||||
WebSocket? _socket;
|
||||
|
||||
Future<void> connect(String serverId) async {
|
||||
_socket = await WebSocket.connect('wss://signaling.music-assistant.io/ws');
|
||||
|
||||
// Send connect request
|
||||
_send({
|
||||
'type': 'connect-request',
|
||||
'server_id': serverId,
|
||||
});
|
||||
|
||||
// Listen for messages
|
||||
_socket!.listen(_handleMessage);
|
||||
}
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
final message = jsonDecode(data);
|
||||
switch (message['type']) {
|
||||
case 'answer':
|
||||
onAnswer?.call(message['sdp']);
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
onIceCandidate?.call(message['candidate']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Implement WebRTCConnection
|
||||
|
||||
```dart
|
||||
class WebRTCConnection {
|
||||
RTCPeerConnection? _peerConnection;
|
||||
RTCDataChannel? _apiChannel;
|
||||
RTCDataChannel? _sendspinChannel;
|
||||
|
||||
Future<void> connect(SignalingClient signaling) async {
|
||||
// Create peer connection with STUN/TURN servers
|
||||
_peerConnection = await createPeerConnection({
|
||||
'iceServers': [
|
||||
{'urls': 'stun:stun.l.google.com:19302'},
|
||||
// TURN servers provided by signaling
|
||||
],
|
||||
});
|
||||
|
||||
// Create data channels BEFORE creating offer
|
||||
_apiChannel = await _peerConnection!.createDataChannel(
|
||||
'ma-api',
|
||||
RTCDataChannelInit()..ordered = true,
|
||||
);
|
||||
|
||||
_sendspinChannel = await _peerConnection!.createDataChannel(
|
||||
'sendspin',
|
||||
RTCDataChannelInit()..ordered = true,
|
||||
);
|
||||
|
||||
// Create and send offer
|
||||
final offer = await _peerConnection!.createOffer();
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
signaling.sendOffer(offer.sdp!);
|
||||
|
||||
// Handle answer from server
|
||||
signaling.onAnswer = (sdp) async {
|
||||
await _peerConnection!.setRemoteDescription(
|
||||
RTCSessionDescription(sdp, 'answer'),
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Implement RemoteBridge
|
||||
|
||||
The bridge translates between the local WebSocket API format and WebRTC data channel messages.
|
||||
|
||||
```dart
|
||||
class RemoteBridge {
|
||||
final WebRTCConnection _webrtc;
|
||||
final StreamController<String> _messageController;
|
||||
|
||||
void sendCommand(String json) {
|
||||
_webrtc.sendToApiChannel(json);
|
||||
}
|
||||
|
||||
Stream<String> get messages => _messageController.stream;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Integrate with MusicAssistantAPI
|
||||
|
||||
```dart
|
||||
class MusicAssistantAPI {
|
||||
WebSocket? _localSocket;
|
||||
RemoteBridge? _remoteBridge;
|
||||
|
||||
Future<void> connect(String host, {String? serverId}) async {
|
||||
try {
|
||||
// Try local connection first
|
||||
_localSocket = await WebSocket.connect('ws://$host/ws');
|
||||
} catch (e) {
|
||||
// Fall back to remote if serverId provided
|
||||
if (serverId != null) {
|
||||
_remoteBridge = RemoteBridge();
|
||||
await _remoteBridge!.connect(serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Implementation Details
|
||||
|
||||
### 1. Channel Creation Order
|
||||
|
||||
The client MUST create data channels BEFORE sending the WebRTC offer. The server expects the client to create:
|
||||
- `ma-api` - for API traffic
|
||||
- `sendspin` - for audio streaming (optional)
|
||||
|
||||
### 2. Health Monitoring
|
||||
|
||||
Implement aggressive health checks because data channels can silently die:
|
||||
|
||||
```dart
|
||||
// Track message counts
|
||||
int _sentCount = 0;
|
||||
int _recvCount = 0;
|
||||
DateTime _lastRecvTime = DateTime.now();
|
||||
|
||||
// Health check every 10 seconds
|
||||
void _healthCheck() {
|
||||
final timeSinceRecv = DateTime.now().difference(_lastRecvTime);
|
||||
if (timeSinceRecv.inSeconds > 30) {
|
||||
// Channel is dead, trigger reconnection
|
||||
_reconnect();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Keepalive Messages
|
||||
|
||||
Send periodic keepalive on the data channel itself (not just signaling):
|
||||
|
||||
```dart
|
||||
void _sendKeepalive() {
|
||||
_apiChannel?.send(RTCDataChannelMessage(
|
||||
jsonEncode({'type': 'ping', 'timestamp': DateTime.now().toIso8601String()}),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Flow Control
|
||||
|
||||
Monitor buffer to prevent overflow:
|
||||
|
||||
```dart
|
||||
void sendMessage(String json) {
|
||||
if (_apiChannel!.bufferedAmount > 1048576) { // 1MB
|
||||
// Queue message, don't send
|
||||
_sendQueue.add(json);
|
||||
return;
|
||||
}
|
||||
_apiChannel!.send(RTCDataChannelMessage(json));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Pitfalls
|
||||
|
||||
### 1. One-Way Channel Death (CRITICAL - UNSOLVED)
|
||||
|
||||
The `ma-api` channel stops receiving after 1-3 minutes while `sendspin` continues working. This occurs in both flutter_webrtc and WebView implementations.
|
||||
|
||||
**Workaround options:**
|
||||
- Detect via health check and force reconnection
|
||||
- Try single-channel multiplexing (see `PLAN_B_SINGLE_CHANNEL.md`)
|
||||
- Report to MA team and wait for server-side fix
|
||||
|
||||
### 2. flutter_webrtc Quirks
|
||||
|
||||
- `RTCDataChannel.state` may show "open" when actually dead
|
||||
- `bufferedAmount` accuracy varies by platform
|
||||
- `onBufferedAmountLow` callback support inconsistent
|
||||
|
||||
### 3. Android Background Throttling
|
||||
|
||||
Android aggressively throttles background WebRTC. Consider:
|
||||
- Foreground service for sustained connections
|
||||
- Reconnection on app resume
|
||||
|
||||
### 4. ICE/TURN Issues
|
||||
|
||||
- aiortc (server-side) has known TURN + DataChannel bugs
|
||||
- May need ICE restart on network changes
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Connect via remote when local unavailable
|
||||
- [ ] Verify API commands work (players/all, library queries)
|
||||
- [ ] Test sustained connection (30+ minutes)
|
||||
- [ ] Test app backgrounding and resume
|
||||
- [ ] Test network transitions (WiFi ↔ cellular)
|
||||
- [ ] Verify reconnection after channel death
|
||||
- [ ] Test sendspin audio streaming (if implemented)
|
||||
|
||||
---
|
||||
|
||||
## Useful Resources
|
||||
|
||||
- [Music Assistant Remote Access Docs](https://music-assistant.io/docs/remote-access/)
|
||||
- [flutter_webrtc Package](https://pub.dev/packages/flutter_webrtc)
|
||||
- [WebRTC Data Channel MDN](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel)
|
||||
- Signaling server: `wss://signaling.music-assistant.io/ws`
|
||||
|
||||
---
|
||||
|
||||
## Files in This Directory
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `FUTURE_IMPLEMENTATION_GUIDE.md` | This file - implementation guide |
|
||||
| `MA_WEBRTC_BUG_REPORT.md` | Bug report for MA team |
|
||||
| `WEBRTC_DISCONNECT_FIXES.md` | All fixes attempted |
|
||||
| `WEBRTC_PROGRESS.md` | Progress log |
|
||||
| `PLAN_B_SINGLE_CHANNEL.md` | Alternative single-channel approach |
|
||||
| `webrtc-failure-logs.txt` | Raw failure logs |
|
||||
| `console-export-*.log` | Browser console exports |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start for Future Implementation
|
||||
|
||||
1. Read this guide fully
|
||||
2. Review `WEBRTC_DISCONNECT_FIXES.md` for all attempted fixes
|
||||
3. Check if MA team has fixed server-side issues
|
||||
4. Start with basic implementation, add health monitoring early
|
||||
5. Test extensively before shipping - the failure mode is subtle
|
||||
@@ -0,0 +1,127 @@
|
||||
# WebRTC Data Channel Failure on Android - Bug Report
|
||||
|
||||
## Summary
|
||||
|
||||
When connecting to Music Assistant via WebRTC remote access (Nabu Casa), the `ma-api` data channel becomes **one-way** (can send, cannot receive) after 1-3 minutes of use, while the `sendspin` channel continues working normally.
|
||||
|
||||
## Environment
|
||||
|
||||
- **MA Server**: 2.8.0b7 (Home Assistant add-on)
|
||||
- **Client**: Android app (Flutter) connecting via Nabu Casa remote access
|
||||
- **Remote Access**: WebRTC via `wss://signaling.music-assistant.io/ws`
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Connect to MA server via WebRTC remote access (not local network)
|
||||
2. Use the app normally - browse library, view players
|
||||
3. After 1-3 minutes, the connection degrades
|
||||
4. Commands time out, but signaling ping/pong continues working
|
||||
|
||||
## Observed Behavior
|
||||
|
||||
### Timeline from logs:
|
||||
|
||||
```
|
||||
07:14:20 - Working normally
|
||||
Health: API sent:51 recv:54 (balanced)
|
||||
|
||||
07:15:57 - Receive stops
|
||||
Health: API sent:97 recv:97, lastRecv:12s ago
|
||||
|
||||
07:16:02 - Timeouts begin
|
||||
"TimeoutException: Command timeout: players/all"
|
||||
|
||||
07:16:17 - Sendspin still works!
|
||||
"Sendspin: Received server/time response"
|
||||
Health: API sent:114 recv:97, lastRecv:27s ago
|
||||
|
||||
07:17:30 - Forced reconnect
|
||||
Health: API sent:137 recv:97, lastRecv:42s ago
|
||||
"Health check detected stale connection"
|
||||
```
|
||||
|
||||
### Key Observation
|
||||
|
||||
| Channel | Traffic Pattern | Status |
|
||||
|---------|-----------------|--------|
|
||||
| `ma-api` | High (10+ msg/s, large payloads) | **FAILS** - stops receiving |
|
||||
| `sendspin` | Low (~1 msg/5s, small payloads) | **SURVIVES** - continues working |
|
||||
| Signaling | Ping/pong every 15s | **SURVIVES** - continues working |
|
||||
|
||||
The WebRTC peer connection remains in "connected" state. ICE connection shows "completed". Only the ma-api data channel stops receiving.
|
||||
|
||||
## Testing Performed
|
||||
|
||||
### Test 1: flutter_webrtc (Native WebRTC)
|
||||
- **Result**: ma-api channel dies after 1-3 minutes
|
||||
- **Sendspin**: Continues working
|
||||
|
||||
### Test 2: WebView-based WebRTC (Browser engine)
|
||||
- **Hypothesis**: Use Android WebView's browser-native WebRTC, since MA Web UI works
|
||||
- **Implementation**: Hidden InAppWebView running JavaScript WebRTC engine
|
||||
- **Result**: **Same failure mode** - ma-api dies, sendspin survives
|
||||
- **Conclusion**: Not a flutter_webrtc bug
|
||||
|
||||
### Test 3: Various Data Channel Settings
|
||||
- Tried `ordered: false` with `maxRetransmits: 3`
|
||||
- Tried letting server create channels (failed - server expects client to create)
|
||||
- No improvement
|
||||
|
||||
## Analysis
|
||||
|
||||
The identical failure in both native and WebView WebRTC implementations suggests the issue is:
|
||||
|
||||
1. **Server-side** - Different handling of ma-api vs sendspin channels
|
||||
2. **TURN relay** - High-traffic channel may hit limits
|
||||
3. **Not client-specific** - Both implementations fail identically
|
||||
|
||||
### Questions for MA Team
|
||||
|
||||
1. Are the two data channels (`ma-api`, `sendspin`) created with different settings?
|
||||
2. Is there any server-side buffering or rate limiting that could affect high-traffic channels?
|
||||
3. Does the MA Web UI use different data channel configurations?
|
||||
4. Are there known issues with the TURN relay for sustained high-traffic channels?
|
||||
|
||||
## Comparison: MA Web UI vs Android App
|
||||
|
||||
| Aspect | MA Web UI | Android App |
|
||||
|--------|-----------|-------------|
|
||||
| Platform | Browser tab (visible, active) | Background-capable app |
|
||||
| WebRTC | Browser native | flutter_webrtc / WebView |
|
||||
| Result | Works | Fails after 1-3 min |
|
||||
|
||||
The MA Web UI runs in a **visible, active browser tab**. Android may throttle background apps differently, but this doesn't explain why sendspin survives while ma-api dies on the same connection.
|
||||
|
||||
## Logs
|
||||
|
||||
### Health Check Pattern (shows channel death)
|
||||
```
|
||||
07:14:25 API sent:51 recv:54, lastRecv:2s ago <- Working
|
||||
07:15:35 API sent:87 recv:91, lastRecv:2s ago <- Working
|
||||
07:16:05 API sent:108 recv:97, lastRecv:17s ago <- DYING (gap growing)
|
||||
07:16:15 API sent:114 recv:97, lastRecv:27s ago <- DEAD
|
||||
07:16:45 API sent:130 recv:97, lastRecv:27s ago <- Still sending, no receive
|
||||
07:17:30 API sent:137 recv:97, lastRecv:42s ago <- Forced reconnect
|
||||
```
|
||||
|
||||
### Sendspin Still Working During Failure
|
||||
```
|
||||
07:16:17 Sendspin: Received server/time response <- Still alive!
|
||||
07:16:47 Sendspin: Received server/time response <- Still alive!
|
||||
```
|
||||
|
||||
## Requested Information
|
||||
|
||||
1. Data channel creation code/settings for both channels
|
||||
2. Any server-side differences in channel handling
|
||||
3. TURN server configuration details
|
||||
4. Whether this has been reported by other Android/mobile clients
|
||||
|
||||
## Workaround
|
||||
|
||||
Currently using local WebSocket connection when on same network. Remote access via WebRTC is unusable for sustained use.
|
||||
|
||||
---
|
||||
|
||||
**Reporter**: Ensemble Android App Developer
|
||||
**Date**: 2026-01-24
|
||||
@@ -0,0 +1,149 @@
|
||||
# Plan B: Single Data Channel Multiplexing
|
||||
|
||||
## Why This Might Be Needed
|
||||
|
||||
If upgrading flutter_webrtc doesn't fix the one-way channel failure, the root cause is likely a bug in how flutter_webrtc (or underlying libwebrtc) handles **multiple data channels**.
|
||||
|
||||
Evidence:
|
||||
- MA Web UI works fine with local playback (same server, browser WebRTC)
|
||||
- Our app uses flutter_webrtc which has known issues:
|
||||
- [Issue #1428](https://github.com/flutter-webrtc/flutter-webrtc/issues/1428): Threading issues causing data loss
|
||||
- [Issue #974](https://github.com/flutter-webrtc/flutter-webrtc/issues/974): EventChannel intermittently fails
|
||||
- Pattern matches: second channel survives while first dies
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ WebRTC Peer Connection │
|
||||
├────────────────────┬────────────────────────────┤
|
||||
│ ma-api channel │ sendspin channel │
|
||||
│ (JSON API) │ (JSON + binary audio) │
|
||||
│ SCTP stream 0 │ SCTP stream 1 │
|
||||
└────────────────────┴────────────────────────────┘
|
||||
```
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ WebRTC Peer Connection │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ unified channel │
|
||||
│ (multiplexed: API + Sendspin text + binary) │
|
||||
│ SCTP stream 0 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Multiplexing Protocol
|
||||
|
||||
### Message Framing
|
||||
|
||||
Each message has a 1-byte type prefix:
|
||||
|
||||
| Prefix | Type | Content |
|
||||
|--------|------|---------|
|
||||
| 0x00 | API | JSON text (MA API request/response) |
|
||||
| 0x01 | Sendspin Text | JSON text (Sendspin control messages) |
|
||||
| 0x02 | Sendspin Binary | Raw bytes (PCM audio data) |
|
||||
|
||||
### Implementation
|
||||
|
||||
#### Sending (Client → Server)
|
||||
|
||||
```dart
|
||||
void sendApiMessage(String json) {
|
||||
final prefix = Uint8List.fromList([0x00]);
|
||||
final payload = utf8.encode(json);
|
||||
final message = Uint8List(1 + payload.length);
|
||||
message[0] = 0x00;
|
||||
message.setRange(1, message.length, payload);
|
||||
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
|
||||
}
|
||||
|
||||
void sendSendspinText(String json) {
|
||||
final prefix = Uint8List.fromList([0x01]);
|
||||
final payload = utf8.encode(json);
|
||||
final message = Uint8List(1 + payload.length);
|
||||
message[0] = 0x01;
|
||||
message.setRange(1, message.length, payload);
|
||||
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
|
||||
}
|
||||
|
||||
void sendSendspinBinary(Uint8List audio) {
|
||||
final message = Uint8List(1 + audio.length);
|
||||
message[0] = 0x02;
|
||||
message.setRange(1, message.length, audio);
|
||||
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
|
||||
}
|
||||
```
|
||||
|
||||
#### Receiving (Server → Client)
|
||||
|
||||
```dart
|
||||
void _onMessage(RTCDataChannelMessage message) {
|
||||
final data = message.binary;
|
||||
if (data == null || data.isEmpty) return;
|
||||
|
||||
final type = data[0];
|
||||
final payload = data.sublist(1);
|
||||
|
||||
switch (type) {
|
||||
case 0x00: // API
|
||||
final json = utf8.decode(payload);
|
||||
_handleApiMessage(json);
|
||||
break;
|
||||
case 0x01: // Sendspin text
|
||||
final json = utf8.decode(payload);
|
||||
_handleSendspinText(json);
|
||||
break;
|
||||
case 0x02: // Sendspin binary
|
||||
_handleSendspinBinary(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Changes Required
|
||||
|
||||
The Music Assistant server would need to:
|
||||
1. Accept a single "unified" data channel instead of separate ma-api and sendspin channels
|
||||
2. Multiplex responses using the same framing protocol
|
||||
3. This requires changes to the MA remote connection code
|
||||
|
||||
**Alternative**: Keep server unchanged, do multiplexing in a proxy layer on the client.
|
||||
|
||||
## Client-Only Approach (No Server Changes)
|
||||
|
||||
If we can't change the server, we could:
|
||||
|
||||
1. Still create two channels to the server (ma-api + sendspin)
|
||||
2. But internally route them through a single "virtual" channel for flutter_webrtc
|
||||
3. This is more complex and may not avoid the underlying bug
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `webrtc_connection.dart` | Single channel creation, multiplexing logic |
|
||||
| `remote_bridge.dart` | Route messages based on type prefix |
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Server compatibility**: Requires MA server changes or complex client proxy
|
||||
2. **Performance**: Extra byte per message, decode overhead
|
||||
3. **Binary handling**: Must handle all messages as binary (not text)
|
||||
|
||||
## Testing Plan
|
||||
|
||||
1. Implement client-side changes
|
||||
2. Test with modified server (if available) or mock
|
||||
3. Verify both API and Sendspin traffic flows correctly
|
||||
4. Long-duration stability test
|
||||
|
||||
## Decision Point
|
||||
|
||||
Try this approach if:
|
||||
- flutter_webrtc 0.14.x doesn't fix the issue
|
||||
- The one-way failure pattern continues
|
||||
- We've confirmed the issue is multi-channel specific
|
||||
@@ -0,0 +1,406 @@
|
||||
# WebRTC Data Channel Disconnect Fixes
|
||||
|
||||
## Document Purpose
|
||||
Track all fixes attempted for the WebRTC data channel disconnection issues in Ensemble's remote connection feature. This document should be referenced when debugging future disconnection problems.
|
||||
|
||||
---
|
||||
|
||||
## Problem Summary
|
||||
|
||||
The WebRTC data channel between the Ensemble mobile app and Music Assistant server can fail in several ways:
|
||||
|
||||
1. **One-way failure**: Data channel appears "open" but only sends work, receives stop
|
||||
2. **Silent disconnect**: No error events fire, channel just stops working
|
||||
3. **ICE failure**: ICE connection fails, properly detected but causes disconnect
|
||||
4. **Signaling disconnect**: Signaling WebSocket closes, causing ICE/WebRTC to fail
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection, data channel management |
|
||||
| `lib/services/remote/remote_bridge.dart` | WebSocket bridge, health monitoring, reconnection logic |
|
||||
| `lib/services/remote/signaling_client.dart` | Signaling server WebSocket connection |
|
||||
|
||||
---
|
||||
|
||||
## Implemented Fixes (Chronological)
|
||||
|
||||
### Fix 1: Data Channel Keepalive (Original)
|
||||
**Problem**: Data channel could silently die without any error events firing.
|
||||
|
||||
**Solution**: Added periodic keepalive ping/pong on the data channel itself.
|
||||
|
||||
**Code** (`webrtc_connection.dart`):
|
||||
- `_keepaliveTimer`: Runs every 10 seconds
|
||||
- `_checkKeepalive()`: Checks time since last data received
|
||||
- `_sendKeepalivePing()`: Sends JSON ping message
|
||||
- `_recordDataReceived()`: Called on every message receipt
|
||||
|
||||
**Constants**:
|
||||
```dart
|
||||
static const _keepaliveInterval = Duration(seconds: 10);
|
||||
static const _keepaliveTimeout = Duration(seconds: 20); // Reduced from 30
|
||||
static const _maxKeepaliveMisses = 2; // Reduced from 3
|
||||
```
|
||||
|
||||
### Fix 2: ICE Recovery
|
||||
**Problem**: ICE connection could temporarily disconnect and might auto-recover.
|
||||
|
||||
**Solution**: Added ICE recovery timer before declaring failure.
|
||||
|
||||
**Code** (`webrtc_connection.dart`):
|
||||
- `_startIceRecovery()`: Starts 5-second timer when ICE disconnects
|
||||
- `_cancelIceRecovery()`: Cancels if ICE recovers on its own
|
||||
- `_attemptIceRestart()`: Calls `restartIce()` if recovery timer expires
|
||||
|
||||
### Fix 3: Flow Control (Buffer Overflow Prevention)
|
||||
**Problem**: Fire-and-forget sends could overflow the 16MB SCTP buffer, causing silent channel death.
|
||||
|
||||
**Solution**: Added send-side flow control with buffering.
|
||||
|
||||
**Code** (`webrtc_connection.dart`):
|
||||
```dart
|
||||
static const _maxBufferedAmount = 1048576; // 1MB threshold
|
||||
static const _bufferLowThreshold = 65536; // 64KB resume threshold
|
||||
bool _sendPaused = false;
|
||||
final _sendQueue = <String>[];
|
||||
```
|
||||
|
||||
**Modified `sendRaw()`**:
|
||||
- Checks `bufferedAmount` before sending
|
||||
- Queues messages if buffer > 1MB
|
||||
- `onBufferedAmountLow` callback drains queue
|
||||
|
||||
### Fix 4: Health Check Stale Detection
|
||||
**Problem**: Health check could see "30s since last data" but not trigger reconnection.
|
||||
|
||||
**Solution**: Reduced stale detection threshold from 45s to 30s.
|
||||
|
||||
**Code** (`remote_bridge.dart`):
|
||||
```dart
|
||||
if (_state == RemoteBridgeState.connected && timeSinceLastData > 30 && !_isReconnecting) {
|
||||
// Force reconnection
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 5: Reduced Keepalive Timeouts
|
||||
**Problem**: 30s timeout × 3 misses = 90s minimum before detection.
|
||||
|
||||
**Solution**: Reduced to 20s timeout × 2 misses = ~40s detection.
|
||||
|
||||
### Fix 6: Recv Stagnation Detection
|
||||
**Problem**: One-way failure where recv stops but sent continues not detected quickly enough.
|
||||
|
||||
**Solution**: Track recv count across health checks. If recv stuck while sent increases for 2 consecutive checks (20s), force reconnection.
|
||||
|
||||
**Code** (`remote_bridge.dart`):
|
||||
```dart
|
||||
// Track across health checks
|
||||
int _lastHealthCheckRecvCount = 0;
|
||||
int _lastHealthCheckSentCount = 0;
|
||||
int _recvStagnationCount = 0;
|
||||
|
||||
// In health check:
|
||||
if (recvStuck && sentIncreased) {
|
||||
_recvStagnationCount++;
|
||||
if (_recvStagnationCount >= 2) {
|
||||
// Force reconnection - channel is one-way
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Detection time**: ~20 seconds (2 × 10s health check interval)
|
||||
|
||||
---
|
||||
|
||||
## Log Patterns to Watch For
|
||||
|
||||
### Healthy Connection
|
||||
```
|
||||
Health check - API sent:93 recv:88 ... lastRecv:0s ago
|
||||
```
|
||||
- `sent` and `recv` both increasing
|
||||
- `lastRecv` low (< 5s typically)
|
||||
|
||||
### One-Way Failure (CRITICAL)
|
||||
```
|
||||
Health check - API sent:206 recv:163 ... lastRecv:30s ago
|
||||
Health check - API sent:218 recv:163 ... lastRecv:40s ago
|
||||
```
|
||||
- `sent` increases, `recv` STUCK
|
||||
- `lastRecv` keeps increasing
|
||||
- Data channel still shows "open"
|
||||
|
||||
### Signaling Disconnect
|
||||
```
|
||||
WebRTC: Signaling disconnected
|
||||
... (6-16 seconds later) ...
|
||||
WebRTC: ICE connection state: RTCIceConnectionStateDisconnected
|
||||
WebRTC: ICE connection state: RTCIceConnectionStateFailed
|
||||
```
|
||||
|
||||
### Network Failure (DNS)
|
||||
```
|
||||
Signaling: WebSocket closed
|
||||
... (reconnection attempt) ...
|
||||
Signaling: Failed to connect: SocketException: Failed host lookup: 'signaling.music-assistant.io'
|
||||
```
|
||||
This indicates the phone lost network connectivity entirely.
|
||||
|
||||
### Reconnection Storm (Signaling Instability)
|
||||
```
|
||||
Signaling: Connecting to wss://signaling.music-assistant.io/ws
|
||||
Signaling: Sending connect-request
|
||||
Signaling: WebSocket closed ← Closes within 100-500ms
|
||||
WebRTC: Signaling disconnected
|
||||
RemoteBridge: Scheduling reconnect attempt X/10 in Ys
|
||||
```
|
||||
Rapid reconnection loop - signaling never stays connected long enough.
|
||||
|
||||
### Flow Control Engaged
|
||||
```
|
||||
WebRTC: ⚠️ Buffer full (1234567 bytes) or paused - queueing message
|
||||
WebRTC: Buffer drained to 12345 bytes - draining 5 queued messages
|
||||
WebRTC: ✅ Send queue drained (5 messages) - resuming normal sends
|
||||
```
|
||||
|
||||
### Keepalive Detection
|
||||
```
|
||||
WebRTC: ⚠️ Keepalive timeout! No data for 22s (missed: 1/2)
|
||||
WebRTC: ❌ Data channel appears dead - triggering reconnection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Limitations
|
||||
|
||||
### 1. flutter_webrtc Limitations
|
||||
- `RTCDataChannel.state` may show "open" when actually dead (Issue #843)
|
||||
- `bufferedAmount` may not be accurate on all platforms
|
||||
- `onBufferedAmountLow` callback support varies
|
||||
|
||||
### 2. SCTP Receive Window
|
||||
- If server sends faster than client processes, receive window fills
|
||||
- This causes server-side to stop sending, client sees no data
|
||||
- No way to monitor receive-side buffer from client
|
||||
|
||||
### 3. TURN Server Issues
|
||||
- aiortc (server-side) has known issues with TURN + DataChannel (#1156)
|
||||
- Data channel may fail silently when using TURN relay
|
||||
|
||||
### 4. Network Transitions
|
||||
- WiFi → Cellular or vice versa can cause ICE to fail
|
||||
- App backgrounding on mobile can close WebSocket/WebRTC
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Categories
|
||||
|
||||
### Category A: Network Issues (Not Code Bugs)
|
||||
These are environmental issues, not bugs in our code:
|
||||
- **DNS failure**: `Failed host lookup` - Phone lost internet
|
||||
- **WiFi/Cellular switch**: Network changed, connections dropped
|
||||
- **Server unreachable**: Signaling server down or blocked
|
||||
- **Firewall/NAT issues**: TURN relay needed but unavailable
|
||||
|
||||
**Evidence**: Signaling WebSocket closes, DNS lookups fail, reconnection attempts fail repeatedly.
|
||||
|
||||
### Category B: WebRTC/SCTP Issues (Partial Code Control)
|
||||
These can be mitigated but not fully prevented:
|
||||
- **One-way channel death**: Channel appears open but data stops flowing
|
||||
- **Buffer overflow**: Too much data sent, SCTP buffer exceeded
|
||||
- **ICE failure**: Network path lost, no recovery possible
|
||||
|
||||
**Evidence**: Data channel shows "open" but `recv` stuck, `lastRecv` increasing.
|
||||
|
||||
### Category C: Code Bugs (Fixable)
|
||||
Issues in our implementation:
|
||||
- **Detection too slow**: Timeouts too long, misses too many
|
||||
- **Recovery not triggered**: State checks wrong, reconnection not started
|
||||
- **Resource leaks**: Timers not cancelled, connections not cleaned up
|
||||
|
||||
**Evidence**: Logs show problem but no action taken, or wrong action taken.
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
When investigating a disconnect:
|
||||
|
||||
1. **Find the last successful response**:
|
||||
```
|
||||
grep "WebRTC API → WS" logs.txt | head -5
|
||||
```
|
||||
|
||||
2. **Check health check progression**:
|
||||
```
|
||||
grep "Health check" logs.txt
|
||||
```
|
||||
Look for `recv` getting stuck while `sent` increases.
|
||||
|
||||
3. **Check for signaling issues**:
|
||||
```
|
||||
grep -i "signaling" logs.txt
|
||||
```
|
||||
|
||||
4. **Check ICE state changes**:
|
||||
```
|
||||
grep "ICE connection state" logs.txt
|
||||
```
|
||||
|
||||
5. **Check for keepalive detection**:
|
||||
```
|
||||
grep -i "keepalive" logs.txt
|
||||
```
|
||||
|
||||
6. **Check buffer status**:
|
||||
```
|
||||
grep -E "(buffer|queue|paused)" logs.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Potential Future Improvements
|
||||
|
||||
### 1. Server-Side Backpressure
|
||||
MA protocol doesn't have explicit backpressure. Could add:
|
||||
- Client-side request throttling
|
||||
- Application-level ACKs for critical messages
|
||||
|
||||
### 2. More Aggressive Detection
|
||||
- Track recv count across health checks, detect stagnation
|
||||
- Shorter keepalive interval (5s instead of 10s)
|
||||
|
||||
### 3. Proactive Health Signals
|
||||
- Monitor `bufferedAmount` trend (increasing = problem)
|
||||
- Track round-trip latency of keepalive pings
|
||||
|
||||
### 4. Connection Quality Metrics
|
||||
- Expose packet loss, jitter from WebRTC stats
|
||||
- Use to predict imminent failure
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
| Scenario | How to Test | Expected Behavior |
|
||||
|----------|-------------|-------------------|
|
||||
| Normal usage | Browse library, play music | No warnings |
|
||||
| Heavy sync | Pull-to-refresh library | Buffer may queue, should drain |
|
||||
| Long session | Leave connected 2+ hours | Should stay connected |
|
||||
| Rapid navigation | Quick tab switching | Brief queue usage, recovers |
|
||||
| Network change | WiFi → Cellular | Should detect and reconnect |
|
||||
| App background | Background for 5+ min | May disconnect, should reconnect |
|
||||
|
||||
---
|
||||
|
||||
## Observed Failure Patterns
|
||||
|
||||
### Pattern 1: One-Way Death After Library Sync
|
||||
**Observed**: 2026-01-23 (multiple times)
|
||||
|
||||
```
|
||||
17:13:06.175 - Last response (playlists sync)
|
||||
17:13:06.264 - Local batch save
|
||||
-- 5 seconds silence --
|
||||
17:13:11.005 - Send players/all → NO RESPONSE
|
||||
17:13:13.914 - ICE state: completed (looks healthy!)
|
||||
17:13:28 - Keepalive detects timeout
|
||||
17:13:38 - Reconnection triggered
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- Often happens after library sync (large batch responses)
|
||||
- ICE shows "completed" even though data channel is dead
|
||||
- Signaling ping/pong continues working
|
||||
- Only data channel receive is affected
|
||||
|
||||
**Hypothesis**:
|
||||
- Server-side SCTP buffer exhausted from sending large responses?
|
||||
- Client receive window full, server blocks?
|
||||
- flutter_webrtc onMessage callback stops firing?
|
||||
|
||||
### Pattern 2: Reconnection Storm / Signaling Instability
|
||||
**Observed**: 2026-01-23 17:30-17:31
|
||||
|
||||
```
|
||||
17:31:05.093 - Signaling: Connecting
|
||||
17:31:05.216 - Signaling: Sending connect-request
|
||||
17:31:05.218 - Signaling: WebSocket closed (123ms later!)
|
||||
17:31:05.218 - WebRTC: Signaling disconnected
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- Signaling WebSocket closes almost immediately after connect
|
||||
- Multiple rapid reconnection attempts (storm)
|
||||
- Connection never stable long enough for data flow
|
||||
- Often happens after network instability
|
||||
- Artist detail, albums etc don't load because connection keeps failing
|
||||
|
||||
**Possible Causes**:
|
||||
- Network instability (WiFi/cellular issues)
|
||||
- Signaling server rate limiting
|
||||
- DNS resolution failures
|
||||
- Server rejecting rapid reconnection attempts
|
||||
|
||||
**Side Effects**:
|
||||
- UI shows errors like "Not connected to Music Assistant server"
|
||||
- Artist/album detail screens fail to load
|
||||
- Players list empty or stale
|
||||
|
||||
### Pattern 3: API Channel Dies While Sendspin Lives
|
||||
**Observed**: 2026-01-23 17:53
|
||||
|
||||
```
|
||||
17:53:35.755 - Send album_tracks request → NO RESPONSE
|
||||
17:53:40.596 - Health check: recv stuck at 197, sent increased
|
||||
17:53:55.204 - Sendspin channel RECEIVES server/time response!
|
||||
17:54:02.790 - Keepalive detects dead API channel
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- API data channel (ma-api) stops receiving
|
||||
- Sendspin data channel continues working normally
|
||||
- Same WebRTC peer connection, different data channels
|
||||
- This proves it's NOT a network issue - Sendspin traffic flows fine
|
||||
|
||||
**Hypothesis**:
|
||||
- flutter_webrtc bug where `onMessage` callback stops firing for one channel
|
||||
- Possible memory/GC issue with the callback
|
||||
- Server-side might be sending to wrong channel?
|
||||
|
||||
**Key Evidence**:
|
||||
- Recv count stays at 197 for API
|
||||
- Sendspin recv count increases (7→8)
|
||||
- Both channels use same peer connection
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Date | Build | Changes |
|
||||
|------|-------|---------|
|
||||
| 2026-01-23 | ~v49 | Initial flow control implementation |
|
||||
| 2026-01-23 | ~v49 | Reduced keepalive timeout 30→20s, misses 3→2 |
|
||||
| 2026-01-23 | ~v49 | Reduced health check stale threshold 45→30s |
|
||||
| 2026-01-23 | ~v49 | Fixed excessive "queue drained" logging |
|
||||
| 2026-01-23 | ~v49 | Created WEBRTC_DISCONNECT_FIXES.md documentation |
|
||||
| 2026-01-23 | ~v49 | Added recv stagnation detection (20s detection) |
|
||||
| 2026-01-23 | ~v49 | Documented Pattern 2: Reconnection storm / signaling instability |
|
||||
| 2026-01-23 | ~v49 | Fixed race condition in _scheduleReconnect (multiple simultaneous calls) |
|
||||
| 2026-01-23 | ~v49 | Added WebSocket close code/reason logging to signaling |
|
||||
| 2026-01-23 | ~v49 | **CRITICAL FIX**: _reconnectWebRTC() was returning immediately due to _isReconnecting already being true |
|
||||
| 2026-01-23 | ~v49 | Fixed old WebRTC callbacks firing during reconnection cleanup (clear callbacks before disconnect) |
|
||||
| 2026-01-23 | ~v49 | Fixed onStateChanged handler calling _handleWebRTCDisconnection during active reconnection |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- flutter_webrtc Issue #843: Data channel state unreliable
|
||||
- aiortc Issue #1156: TURN + DataChannel bugs
|
||||
- WebRTC SCTP: Max buffer is 16MB, recommended threshold 1MB
|
||||
- Music Assistant remote protocol: No explicit flow control
|
||||
@@ -0,0 +1,118 @@
|
||||
# WebRTC Remote Connection Progress
|
||||
|
||||
## Problem Statement
|
||||
The flutter_webrtc library has reliability issues causing data channel failures during remote connections via Nabu Casa. The ma-api data channel becomes one-way (can send, but receives stop) after some time, while the sendspin channel continues working.
|
||||
|
||||
## Approaches Tried
|
||||
|
||||
### 1. Original Implementation (flutter_webrtc)
|
||||
- **Status**: Fails
|
||||
- **Issue**: ma-api data channel goes one-way after some time
|
||||
- **Files**: `lib/services/remote/webrtc_connection.dart`
|
||||
|
||||
### 2. WebView-based WebRTC Implementation
|
||||
- **Status**: Same failure mode
|
||||
- **Hypothesis**: Use browser-native WebRTC via InAppWebView, since MA Web UI works
|
||||
- **Result**: Same one-way channel death occurs
|
||||
- **Key Finding**: This proves the issue is NOT specific to flutter_webrtc
|
||||
|
||||
## Key Observations
|
||||
|
||||
From the logs:
|
||||
```
|
||||
lastRecv:26s ago <- No messages received in 26 seconds
|
||||
API sent:674 recv:618 <- Gap growing, responses stopped
|
||||
```
|
||||
|
||||
- Signaling ping/pong continues working
|
||||
- Sendspin channel continues receiving (`server/time` responses)
|
||||
- Only ma-api channel becomes one-way
|
||||
- WebRTC peer connection itself remains "connected"
|
||||
|
||||
## Differences from Working MA Web UI
|
||||
|
||||
The MA Web UI runs in a **visible, active browser tab**. Our implementations:
|
||||
1. flutter_webrtc: Native WebRTC in background app
|
||||
2. WebView: Browser WebRTC in **hidden 1x1 pixel WebView**
|
||||
|
||||
Android aggressively throttles hidden WebViews to save battery.
|
||||
|
||||
## Current Implementation (WebView)
|
||||
|
||||
### Files Created
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/remote/webview_webrtc_connection.dart` | Dart WebView wrapper |
|
||||
| `assets/webrtc_engine.js` | JavaScript WebRTC engine |
|
||||
|
||||
### Files Modified
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `pubspec.yaml` | Added `flutter_inappwebview: ^6.0.0` |
|
||||
| `lib/services/remote/remote_bridge.dart` | Uses `WebViewWebRTCConnection` |
|
||||
| `lib/providers/music_assistant_provider.dart` | Exposes WebView widget |
|
||||
| `lib/main.dart` | Adds WebView to widget tree |
|
||||
|
||||
### Architecture
|
||||
```
|
||||
MusicAssistantAPI -> localhost WebSocket -> RemoteBridge
|
||||
|
|
||||
WebViewWebRTCConnection
|
||||
|
|
||||
Hidden InAppWebView
|
||||
|
|
||||
JavaScript WebRTC Engine
|
||||
|
|
||||
MA Server via TURN
|
||||
```
|
||||
|
||||
## Current Fixes Being Tested
|
||||
|
||||
### 1. Prevent WebView Throttling
|
||||
- Changed WebView position from off-screen (-10,-10) to on-screen (0,0) with Opacity(0)
|
||||
- Added various InAppWebViewSettings to prevent background throttling
|
||||
|
||||
### 2. Data Channel Settings
|
||||
- Changed from `ordered: true` to `ordered: false` with `maxRetransmits: 3`
|
||||
- Hypothesis: Ordered delivery may cause buffer bloat issues
|
||||
|
||||
### 3. Buffer Monitoring
|
||||
- Added `bufferedAmount` checking in JavaScript
|
||||
- Logs warning when buffer exceeds 1MB
|
||||
- Added `getChannelHealth()` function for diagnostics
|
||||
|
||||
### 4. Reduced Logging
|
||||
- Removed per-message logging (was filling logs too fast)
|
||||
- Keep only state changes, errors, and warnings
|
||||
|
||||
## Experiments Completed
|
||||
|
||||
### 5. Server-Created Channels (Failed)
|
||||
- **Hypothesis**: Let server create data channels to avoid ID collisions
|
||||
- **Result**: Connection timeout - server expects CLIENT to create channels
|
||||
- **Learning**: Client must create channels before sending offer
|
||||
|
||||
## Next Steps to Try
|
||||
|
||||
1. **Make WebView actually visible** - Android may still throttle Opacity(0) WebView
|
||||
2. **Single data channel** - Use only ma-api, disable sendspin
|
||||
3. **Periodic channel recreation** - Detect stale channel and recreate
|
||||
4. **Report to MA team** - Browser WebRTC also fails, suggesting server-side issue
|
||||
|
||||
## Test APK Location
|
||||
`/home/chris/Ensemble/ensemble-webview-webrtc.apk`
|
||||
|
||||
## Useful Debug Commands
|
||||
|
||||
Check channel health (in JS console or via Dart):
|
||||
```javascript
|
||||
getChannelHealth()
|
||||
// Returns: { peerConnection, iceConnection, apiChannel, apiBuffered, sendspinChannel, sendspinBuffered, signalingConnected }
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Why does sendspin channel survive when ma-api dies?
|
||||
2. Is there a difference in how MA server handles the two channels?
|
||||
3. Does MA Web UI use different data channel settings?
|
||||
4. Is there a TURN relay issue specific to high-traffic channels?
|
||||
@@ -0,0 +1,2 @@
|
||||
New package ID (com.collotsspot.ensemble) - reinstall required.
|
||||
Prepares for F-Droid and Play Store submission.
|
||||
@@ -0,0 +1 @@
|
||||
Add fastlane metadata for F-Droid.
|
||||
@@ -0,0 +1 @@
|
||||
Disable dependency metadata in APK for F-Droid compliance.
|
||||
@@ -0,0 +1,13 @@
|
||||
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
|
||||
|
||||
Features:
|
||||
* Stream music directly to your phone with background playback
|
||||
* Control any speaker or device connected to Music Assistant
|
||||
* Multi-room grouping capabilities
|
||||
* Queue management with drag-to-reorder
|
||||
* Background playback with media notifications
|
||||
* Fast ~300ms response for play/pause controls
|
||||
|
||||
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
|
||||
|
||||
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 909 KiB |
@@ -0,0 +1 @@
|
||||
Unofficial mobile client for Music Assistant
|
||||
@@ -0,0 +1 @@
|
||||
Ensemble
|
||||
@@ -32,6 +32,12 @@ class HeroTags {
|
||||
/// Podcast title hero tag prefix
|
||||
static const String podcastTitle = 'podcast_title_';
|
||||
|
||||
/// Podcast episode cover hero tag prefix
|
||||
static const String podcastEpisodeCover = 'podcast_episode_cover_';
|
||||
|
||||
/// Podcast episode title hero tag prefix
|
||||
static const String podcastEpisodeTitle = 'podcast_episode_title_';
|
||||
|
||||
/// Playlist cover hero tag prefix
|
||||
static const String playlistCover = 'playlist_cover_';
|
||||
|
||||
|
||||
@@ -10,4 +10,7 @@ class NetworkConstants {
|
||||
|
||||
/// Default HTTP port
|
||||
static const int defaultHttpPort = 80;
|
||||
|
||||
/// Maximum number of pending WebSocket requests to prevent memory leaks
|
||||
static const int maxPendingRequests = 100;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ class LibraryCache extends Table {
|
||||
/// Whether this item was deleted on the server
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {cacheKey};
|
||||
}
|
||||
@@ -186,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 => 5;
|
||||
int get schemaVersion => 7;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -226,6 +255,14 @@ class AppDatabase extends _$AppDatabase {
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)');
|
||||
}
|
||||
// Migration from v5 to v6: Add source_providers column for client-side filtering
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -368,20 +405,57 @@ class AppDatabase extends _$AppDatabase {
|
||||
// ============================================
|
||||
|
||||
/// Cache a library item
|
||||
/// [sourceProvider] - the provider instance ID that provided this item (for filtering)
|
||||
Future<void> cacheItem({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required String data,
|
||||
String? sourceProvider,
|
||||
}) async {
|
||||
final cacheKey = '${itemType}_$itemId';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
// If sourceProvider is specified, we need to merge with existing providers
|
||||
if (sourceProvider != null) {
|
||||
final existing = await getCachedItem(itemType, itemId);
|
||||
List<String> providers = [];
|
||||
if (existing != null) {
|
||||
try {
|
||||
final existingProviders = existing.sourceProviders;
|
||||
if (existingProviders.isNotEmpty && existingProviders != '[]') {
|
||||
providers = List<String>.from(
|
||||
(await Future.value(existingProviders))
|
||||
.replaceAll('[', '')
|
||||
.replaceAll(']', '')
|
||||
.replaceAll('"', '')
|
||||
.split(',')
|
||||
.where((s) => s.trim().isNotEmpty)
|
||||
.map((s) => s.trim()),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!providers.contains(sourceProvider)) {
|
||||
providers.add(sourceProvider);
|
||||
}
|
||||
final sourceProvidersJson = '[${providers.map((p) => '"$p"').join(',')}]';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
sourceProviders: Value(sourceProvidersJson),
|
||||
));
|
||||
} else {
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached items by type
|
||||
@@ -650,4 +724,126 @@ class AppDatabase extends _$AppDatabase {
|
||||
Future<void> clearSearchHistory() async {
|
||||
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
|
||||
// ============================================
|
||||
|
||||
/// Batch size for chunked inserts to prevent SQLite limits
|
||||
static const int _batchChunkSize = 500;
|
||||
|
||||
/// Batch cache multiple items efficiently using drift batch API
|
||||
/// This is much faster than individual inserts for large datasets.
|
||||
/// Items are chunked into batches of 500 to prevent SQLite variable limits.
|
||||
///
|
||||
/// [items] - List of items to cache, each containing:
|
||||
/// - itemType: 'album', 'artist', 'track', 'playlist', 'audiobook', etc.
|
||||
/// - itemId: the item's ID
|
||||
/// - data: JSON string of the item
|
||||
/// - sourceProviders: list of provider instance IDs
|
||||
Future<void> batchCacheItems(List<BatchCacheItem> items) async {
|
||||
if (items.isEmpty) return;
|
||||
|
||||
// Process in chunks to avoid SQLite variable limits
|
||||
for (var i = 0; i < items.length; i += _batchChunkSize) {
|
||||
final chunk = items.skip(i).take(_batchChunkSize).toList();
|
||||
|
||||
await batch((b) {
|
||||
for (final item in chunk) {
|
||||
final cacheKey = '${item.itemType}_${item.itemId}';
|
||||
final sourceProvidersJson = item.sourceProviders.isEmpty
|
||||
? '[]'
|
||||
: '[${item.sourceProviders.map((p) => '"$p"').join(',')}]';
|
||||
|
||||
b.insert(
|
||||
libraryCache,
|
||||
LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: item.itemType,
|
||||
itemId: item.itemId,
|
||||
data: item.data,
|
||||
lastSynced: DateTime.now(),
|
||||
sourceProviders: Value(sourceProvidersJson),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data class for batch cache operations
|
||||
class BatchCacheItem {
|
||||
final String itemType;
|
||||
final String itemId;
|
||||
final String data;
|
||||
final List<String> sourceProviders;
|
||||
|
||||
const BatchCacheItem({
|
||||
required this.itemType,
|
||||
required this.itemId,
|
||||
required this.data,
|
||||
this.sourceProviders = const [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -856,9 +856,24 @@ class $LibraryCacheTable extends LibraryCache
|
||||
defaultConstraints:
|
||||
GeneratedColumn.constraintIsAlways('CHECK ("is_deleted" IN (0, 1))'),
|
||||
defaultValue: const Constant(false));
|
||||
static const VerificationMeta _sourceProvidersMeta =
|
||||
const VerificationMeta('sourceProviders');
|
||||
@override
|
||||
List<GeneratedColumn> get $columns =>
|
||||
[cacheKey, itemType, itemId, data, lastSynced, isDeleted];
|
||||
late final GeneratedColumn<String> sourceProviders = GeneratedColumn<String>(
|
||||
'source_providers', aliasedName, false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const Constant('[]'));
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
cacheKey,
|
||||
itemType,
|
||||
itemId,
|
||||
data,
|
||||
lastSynced,
|
||||
isDeleted,
|
||||
sourceProviders
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
@@ -905,6 +920,12 @@ class $LibraryCacheTable extends LibraryCache
|
||||
context.handle(_isDeletedMeta,
|
||||
isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta));
|
||||
}
|
||||
if (data.containsKey('source_providers')) {
|
||||
context.handle(
|
||||
_sourceProvidersMeta,
|
||||
sourceProviders.isAcceptableOrUnknown(
|
||||
data['source_providers']!, _sourceProvidersMeta));
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -926,6 +947,8 @@ class $LibraryCacheTable extends LibraryCache
|
||||
.read(DriftSqlType.dateTime, data['${effectivePrefix}last_synced'])!,
|
||||
isDeleted: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.bool, data['${effectivePrefix}is_deleted'])!,
|
||||
sourceProviders: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string, data['${effectivePrefix}source_providers'])!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -954,13 +977,18 @@ class LibraryCacheData extends DataClass
|
||||
|
||||
/// Whether this item was deleted on the server
|
||||
final bool isDeleted;
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
final String sourceProviders;
|
||||
const LibraryCacheData(
|
||||
{required this.cacheKey,
|
||||
required this.itemType,
|
||||
required this.itemId,
|
||||
required this.data,
|
||||
required this.lastSynced,
|
||||
required this.isDeleted});
|
||||
required this.isDeleted,
|
||||
required this.sourceProviders});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
@@ -970,6 +998,7 @@ class LibraryCacheData extends DataClass
|
||||
map['data'] = Variable<String>(data);
|
||||
map['last_synced'] = Variable<DateTime>(lastSynced);
|
||||
map['is_deleted'] = Variable<bool>(isDeleted);
|
||||
map['source_providers'] = Variable<String>(sourceProviders);
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -981,6 +1010,7 @@ class LibraryCacheData extends DataClass
|
||||
data: Value(data),
|
||||
lastSynced: Value(lastSynced),
|
||||
isDeleted: Value(isDeleted),
|
||||
sourceProviders: Value(sourceProviders),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -994,6 +1024,7 @@ class LibraryCacheData extends DataClass
|
||||
data: serializer.fromJson<String>(json['data']),
|
||||
lastSynced: serializer.fromJson<DateTime>(json['lastSynced']),
|
||||
isDeleted: serializer.fromJson<bool>(json['isDeleted']),
|
||||
sourceProviders: serializer.fromJson<String>(json['sourceProviders']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -1006,6 +1037,7 @@ class LibraryCacheData extends DataClass
|
||||
'data': serializer.toJson<String>(data),
|
||||
'lastSynced': serializer.toJson<DateTime>(lastSynced),
|
||||
'isDeleted': serializer.toJson<bool>(isDeleted),
|
||||
'sourceProviders': serializer.toJson<String>(sourceProviders),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1015,7 +1047,8 @@ class LibraryCacheData extends DataClass
|
||||
String? itemId,
|
||||
String? data,
|
||||
DateTime? lastSynced,
|
||||
bool? isDeleted}) =>
|
||||
bool? isDeleted,
|
||||
String? sourceProviders}) =>
|
||||
LibraryCacheData(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
itemType: itemType ?? this.itemType,
|
||||
@@ -1023,6 +1056,7 @@ class LibraryCacheData extends DataClass
|
||||
data: data ?? this.data,
|
||||
lastSynced: lastSynced ?? this.lastSynced,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
sourceProviders: sourceProviders ?? this.sourceProviders,
|
||||
);
|
||||
LibraryCacheData copyWithCompanion(LibraryCacheCompanion data) {
|
||||
return LibraryCacheData(
|
||||
@@ -1033,6 +1067,9 @@ class LibraryCacheData extends DataClass
|
||||
lastSynced:
|
||||
data.lastSynced.present ? data.lastSynced.value : this.lastSynced,
|
||||
isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted,
|
||||
sourceProviders: data.sourceProviders.present
|
||||
? data.sourceProviders.value
|
||||
: this.sourceProviders,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1044,14 +1081,15 @@ class LibraryCacheData extends DataClass
|
||||
..write('itemId: $itemId, ')
|
||||
..write('data: $data, ')
|
||||
..write('lastSynced: $lastSynced, ')
|
||||
..write('isDeleted: $isDeleted')
|
||||
..write('isDeleted: $isDeleted, ')
|
||||
..write('sourceProviders: $sourceProviders')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(cacheKey, itemType, itemId, data, lastSynced, isDeleted);
|
||||
int get hashCode => Object.hash(
|
||||
cacheKey, itemType, itemId, data, lastSynced, isDeleted, sourceProviders);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
@@ -1061,7 +1099,8 @@ class LibraryCacheData extends DataClass
|
||||
other.itemId == this.itemId &&
|
||||
other.data == this.data &&
|
||||
other.lastSynced == this.lastSynced &&
|
||||
other.isDeleted == this.isDeleted);
|
||||
other.isDeleted == this.isDeleted &&
|
||||
other.sourceProviders == this.sourceProviders);
|
||||
}
|
||||
|
||||
class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
@@ -1071,6 +1110,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
final Value<String> data;
|
||||
final Value<DateTime> lastSynced;
|
||||
final Value<bool> isDeleted;
|
||||
final Value<String> sourceProviders;
|
||||
final Value<int> rowid;
|
||||
const LibraryCacheCompanion({
|
||||
this.cacheKey = const Value.absent(),
|
||||
@@ -1079,6 +1119,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
this.data = const Value.absent(),
|
||||
this.lastSynced = const Value.absent(),
|
||||
this.isDeleted = const Value.absent(),
|
||||
this.sourceProviders = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
LibraryCacheCompanion.insert({
|
||||
@@ -1088,6 +1129,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
required String data,
|
||||
required DateTime lastSynced,
|
||||
this.isDeleted = const Value.absent(),
|
||||
this.sourceProviders = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : cacheKey = Value(cacheKey),
|
||||
itemType = Value(itemType),
|
||||
@@ -1101,6 +1143,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
Expression<String>? data,
|
||||
Expression<DateTime>? lastSynced,
|
||||
Expression<bool>? isDeleted,
|
||||
Expression<String>? sourceProviders,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
@@ -1110,6 +1153,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
if (data != null) 'data': data,
|
||||
if (lastSynced != null) 'last_synced': lastSynced,
|
||||
if (isDeleted != null) 'is_deleted': isDeleted,
|
||||
if (sourceProviders != null) 'source_providers': sourceProviders,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
@@ -1121,6 +1165,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
Value<String>? data,
|
||||
Value<DateTime>? lastSynced,
|
||||
Value<bool>? isDeleted,
|
||||
Value<String>? sourceProviders,
|
||||
Value<int>? rowid}) {
|
||||
return LibraryCacheCompanion(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
@@ -1129,6 +1174,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
data: data ?? this.data,
|
||||
lastSynced: lastSynced ?? this.lastSynced,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
sourceProviders: sourceProviders ?? this.sourceProviders,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
@@ -1154,6 +1200,9 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
if (isDeleted.present) {
|
||||
map['is_deleted'] = Variable<bool>(isDeleted.value);
|
||||
}
|
||||
if (sourceProviders.present) {
|
||||
map['source_providers'] = Variable<String>(sourceProviders.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
@@ -1169,6 +1218,7 @@ class LibraryCacheCompanion extends UpdateCompanion<LibraryCacheData> {
|
||||
..write('data: $data, ')
|
||||
..write('lastSynced: $lastSynced, ')
|
||||
..write('isDeleted: $isDeleted, ')
|
||||
..write('sourceProviders: $sourceProviders, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
@@ -2866,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);
|
||||
@@ -2878,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)');
|
||||
@@ -2908,6 +3335,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
cachedQueue,
|
||||
homeRowCache,
|
||||
searchHistory,
|
||||
detailTrackCache,
|
||||
idxRecentlyPlayedProfile,
|
||||
idxRecentlyPlayedProfilePlayed,
|
||||
idxLibraryCacheType,
|
||||
@@ -2946,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(
|
||||
@@ -3139,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),
|
||||
@@ -3204,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(
|
||||
@@ -3507,6 +3939,7 @@ typedef $$LibraryCacheTableCreateCompanionBuilder = LibraryCacheCompanion
|
||||
required String data,
|
||||
required DateTime lastSynced,
|
||||
Value<bool> isDeleted,
|
||||
Value<String> sourceProviders,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$LibraryCacheTableUpdateCompanionBuilder = LibraryCacheCompanion
|
||||
@@ -3517,6 +3950,7 @@ typedef $$LibraryCacheTableUpdateCompanionBuilder = LibraryCacheCompanion
|
||||
Value<String> data,
|
||||
Value<DateTime> lastSynced,
|
||||
Value<bool> isDeleted,
|
||||
Value<String> sourceProviders,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
@@ -3546,6 +3980,10 @@ class $$LibraryCacheTableFilterComposer
|
||||
|
||||
ColumnFilters<bool> get isDeleted => $composableBuilder(
|
||||
column: $table.isDeleted, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get sourceProviders => $composableBuilder(
|
||||
column: $table.sourceProviders,
|
||||
builder: (column) => ColumnFilters(column));
|
||||
}
|
||||
|
||||
class $$LibraryCacheTableOrderingComposer
|
||||
@@ -3574,6 +4012,10 @@ class $$LibraryCacheTableOrderingComposer
|
||||
|
||||
ColumnOrderings<bool> get isDeleted => $composableBuilder(
|
||||
column: $table.isDeleted, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get sourceProviders => $composableBuilder(
|
||||
column: $table.sourceProviders,
|
||||
builder: (column) => ColumnOrderings(column));
|
||||
}
|
||||
|
||||
class $$LibraryCacheTableAnnotationComposer
|
||||
@@ -3602,6 +4044,9 @@ class $$LibraryCacheTableAnnotationComposer
|
||||
|
||||
GeneratedColumn<bool> get isDeleted =>
|
||||
$composableBuilder(column: $table.isDeleted, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get sourceProviders => $composableBuilder(
|
||||
column: $table.sourceProviders, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
@@ -3636,6 +4081,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
Value<String> data = const Value.absent(),
|
||||
Value<DateTime> lastSynced = const Value.absent(),
|
||||
Value<bool> isDeleted = const Value.absent(),
|
||||
Value<String> sourceProviders = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
LibraryCacheCompanion(
|
||||
@@ -3645,6 +4091,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
data: data,
|
||||
lastSynced: lastSynced,
|
||||
isDeleted: isDeleted,
|
||||
sourceProviders: sourceProviders,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback: ({
|
||||
@@ -3654,6 +4101,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
required String data,
|
||||
required DateTime lastSynced,
|
||||
Value<bool> isDeleted = const Value.absent(),
|
||||
Value<String> sourceProviders = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
LibraryCacheCompanion.insert(
|
||||
@@ -3663,6 +4111,7 @@ class $$LibraryCacheTableTableManager extends RootTableManager<
|
||||
data: data,
|
||||
lastSynced: lastSynced,
|
||||
isDeleted: isDeleted,
|
||||
sourceProviders: sourceProviders,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
@@ -4630,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;
|
||||
@@ -4652,4 +5293,6 @@ class $AppDatabaseManager {
|
||||
$$HomeRowCacheTableTableManager(_db, _db.homeRowCache);
|
||||
$$SearchHistoryTableTableManager get searchHistory =>
|
||||
$$SearchHistoryTableTableManager(_db, _db.searchHistory);
|
||||
$$DetailTrackCacheTableTableManager get detailTrackCache =>
|
||||
$$DetailTrackCacheTableTableManager(_db, _db.detailTrackCache);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"description": "Hint for port field"
|
||||
},
|
||||
|
||||
"portDescription": "Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.",
|
||||
"portDescription": "Leave blank unless using a custom port",
|
||||
"@portDescription": {
|
||||
"description": "Description for port field"
|
||||
},
|
||||
@@ -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"
|
||||
@@ -604,6 +634,11 @@
|
||||
|
||||
"tracksAddedToQueue": "Tracks added to queue",
|
||||
"@tracksAddedToQueue": {
|
||||
"description": "Snackbar message for tracks added to queue"
|
||||
},
|
||||
|
||||
"trackAddedToQueue": "Track added to queue",
|
||||
"@trackAddedToQueue": {
|
||||
"description": "Confirmation when tracks added"
|
||||
},
|
||||
|
||||
@@ -1069,6 +1104,10 @@
|
||||
"sortAlphabetically": "Sort alphabetically",
|
||||
"sortByYear": "Sort by year",
|
||||
"sortBySeriesOrder": "Sort by series order",
|
||||
"sortByDuration": "Sort by duration",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
"viewDetails": "View details",
|
||||
"listView": "List view",
|
||||
"gridView": "Grid view",
|
||||
"noBooksInSeries": "No books found in this series",
|
||||
@@ -1087,11 +1126,12 @@
|
||||
"showFavoritePodcasts": "Show a row of your favorite podcasts",
|
||||
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
|
||||
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
||||
"favorite": "Favorite",
|
||||
"addedToFavorites": "Added to favorites",
|
||||
"removedFromFavorites": "Removed from favorites",
|
||||
"addedToLibrary": "Added to library",
|
||||
"removedFromLibrary": "Removed from library",
|
||||
"addToLibrary": "Add to library",
|
||||
"addToLibrary": "Add",
|
||||
"unknown": "Unknown",
|
||||
"noUpcomingTracks": "No upcoming tracks",
|
||||
"showAll": "Show all",
|
||||
@@ -1140,6 +1180,10 @@
|
||||
"audiobookLibraries": "Audiobook Libraries",
|
||||
"chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include",
|
||||
"unknownLibrary": "Unknown Library",
|
||||
"musicProviders": "Music Providers",
|
||||
"musicProvidersDescription": "Choose which accounts to show in your library",
|
||||
"libraryRefreshing": "Refreshing library...",
|
||||
"cannotDisableLastProvider": "Cannot disable the last provider",
|
||||
"noSeriesFound": "No Series Found",
|
||||
"ensembleBugReport": "Ensemble Bug Report",
|
||||
"byOwner": "By {owner}",
|
||||
@@ -1203,11 +1247,21 @@
|
||||
"description": "Bottom sheet title for adding album to queue"
|
||||
},
|
||||
|
||||
"addToQueue": "Add to queue",
|
||||
"@addToQueue": {
|
||||
"description": "Add item to queue on selected player"
|
||||
},
|
||||
|
||||
"addToQueueOn": "Add to queue on...",
|
||||
"@addToQueueOn": {
|
||||
"description": "Bottom sheet title for adding to queue"
|
||||
},
|
||||
|
||||
"startRadio": "Start radio",
|
||||
"@startRadio": {
|
||||
"description": "Start radio on selected player"
|
||||
},
|
||||
|
||||
"startRadioOn": "Start {name} radio on...",
|
||||
"@startRadioOn": {
|
||||
"description": "Bottom sheet title for starting radio",
|
||||
@@ -1288,6 +1342,16 @@
|
||||
"description": "Description for smart sort players setting"
|
||||
},
|
||||
|
||||
"disableAutoSwitch": "Disable Auto-Switch",
|
||||
"@disableAutoSwitch": {
|
||||
"description": "Setting to disable automatic player switching"
|
||||
},
|
||||
|
||||
"disableAutoSwitchDescription": "Never automatically switch to a different player",
|
||||
"@disableAutoSwitchDescription": {
|
||||
"description": "Description for disable auto-switch setting"
|
||||
},
|
||||
|
||||
"playerStateUnavailable": "Unavailable",
|
||||
"@playerStateUnavailable": {
|
||||
"description": "Player state when unavailable"
|
||||
@@ -1343,6 +1407,31 @@
|
||||
"description": "Description for show hints toggle"
|
||||
},
|
||||
|
||||
"volumePrecisionMode": "Volume Precision Mode",
|
||||
"@volumePrecisionMode": {
|
||||
"description": "Setting for fine volume control"
|
||||
},
|
||||
|
||||
"volumePrecisionModeDescription": "Hold still while adjusting volume for fine control",
|
||||
"@volumePrecisionModeDescription": {
|
||||
"description": "Description for volume precision mode setting"
|
||||
},
|
||||
|
||||
"display": "Display",
|
||||
"@display": {
|
||||
"description": "Settings section title for display options"
|
||||
},
|
||||
|
||||
"showProviderIcons": "Show Provider Icons",
|
||||
"@showProviderIcons": {
|
||||
"description": "Toggle label for showing provider icons on album art"
|
||||
},
|
||||
|
||||
"showProviderIconsDescription": "Display provider logos on album art",
|
||||
"@showProviderIconsDescription": {
|
||||
"description": "Description for show provider icons toggle"
|
||||
},
|
||||
|
||||
"pullToSelectPlayers": "Pull to select players",
|
||||
"@pullToSelectPlayers": {
|
||||
"description": "Hint shown when mini player bounces"
|
||||
@@ -1431,5 +1520,253 @@
|
||||
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
|
||||
"@longPressPodcastHint": {
|
||||
"description": "Hint for favoriting podcasts"
|
||||
},
|
||||
|
||||
"failedToPlayRadioStation": "Failed to play radio station: {error}",
|
||||
"@failedToPlayRadioStation": {
|
||||
"description": "Error when playing radio station fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"itemAlreadyInLibrary": "Item is already in library",
|
||||
"@itemAlreadyInLibrary": {
|
||||
"description": "Message when trying to add item already in library"
|
||||
},
|
||||
|
||||
"failedToAddToLibrary": "Failed to add to library",
|
||||
"@failedToAddToLibrary": {
|
||||
"description": "Error when adding to library fails"
|
||||
},
|
||||
|
||||
"cannotFindLibraryId": "Cannot find library ID for removal",
|
||||
"@cannotFindLibraryId": {
|
||||
"description": "Error when library ID not found for removal"
|
||||
},
|
||||
|
||||
"failedToRemoveFromLibrary": "Failed to remove from library",
|
||||
"@failedToRemoveFromLibrary": {
|
||||
"description": "Error when removing from library fails"
|
||||
},
|
||||
|
||||
"failedToPlayAlbum": "Failed to play album: {error}",
|
||||
"@failedToPlayAlbum": {
|
||||
"description": "Error when playing album fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"playingOnPlayer": "Playing {name} on {playerName}",
|
||||
"@playingOnPlayer": {
|
||||
"description": "Status when playing item on specific player",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayPlaylist": "Failed to play playlist: {error}",
|
||||
"@failedToPlayPlaylist": {
|
||||
"description": "Error when playing playlist fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayAudiobook": "Failed to play audiobook: {error}",
|
||||
"@failedToPlayAudiobook": {
|
||||
"description": "Error when playing audiobook fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"noOtherPlayersAvailable": "No other players available",
|
||||
"@noOtherPlayersAvailable": {
|
||||
"description": "Message when no other players available for transfer"
|
||||
},
|
||||
|
||||
"transferQueue": "Transfer queue",
|
||||
"@transferQueue": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"clearQueue": "Clear queue",
|
||||
"@clearQueue": {
|
||||
"description": "Tooltip for clear queue button"
|
||||
},
|
||||
|
||||
"syncingPlayer": "Syncing {playerName}...",
|
||||
"@syncingPlayer": {
|
||||
"description": "Status when syncing player",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"switchPlayer": "Switch Player",
|
||||
"@switchPlayer": {
|
||||
"description": "Label for switch player action"
|
||||
},
|
||||
|
||||
"logsCopiedToClipboard": "Logs copied to clipboard",
|
||||
"@logsCopiedToClipboard": {
|
||||
"description": "Confirmation when logs copied"
|
||||
},
|
||||
|
||||
"auto": "Auto",
|
||||
"@auto": {
|
||||
"description": "Auto option for language selection"
|
||||
},
|
||||
|
||||
"transferQueueTo": "Transfer Queue To",
|
||||
"@transferQueueTo": {
|
||||
"description": "Title for transfer queue dialog"
|
||||
},
|
||||
|
||||
"queueTransferredTo": "Queue transferred to {playerName}",
|
||||
"@queueTransferredTo": {
|
||||
"description": "Success message when queue transferred",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToTransferQueue": "Failed to transfer queue: {error}",
|
||||
"@failedToTransferQueue": {
|
||||
"description": "Error when queue transfer fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToLoadQueue": "Failed to load queue: {error}",
|
||||
"@failedToLoadQueue": {
|
||||
"description": "Error when queue loading fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"transferTo": "Transfer to...",
|
||||
"@transferTo": {
|
||||
"description": "Label for transfer destination selection"
|
||||
},
|
||||
|
||||
"transferQueueToPlayer": "Transfer queue to another player",
|
||||
"@transferQueueToPlayer": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"selectPlayerToPlay": "Select a player",
|
||||
"@selectPlayerToPlay": {
|
||||
"description": "Generic hint for selecting a player in player selector"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayAlbum": "Select player to play album",
|
||||
"@selectPlayerToPlayAlbum": {
|
||||
"description": "Hint for selecting player to play album"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayPlaylist": "Select player to play playlist",
|
||||
"@selectPlayerToPlayPlaylist": {
|
||||
"description": "Hint for selecting player to play playlist"
|
||||
},
|
||||
|
||||
"selectPlayerForRadio": "Select player for radio",
|
||||
"@selectPlayerForRadio": {
|
||||
"description": "Hint for selecting player to start radio"
|
||||
},
|
||||
|
||||
"selectPlayerForEpisode": "Select player for episode",
|
||||
"@selectPlayerForEpisode": {
|
||||
"description": "Hint for selecting player to play episode"
|
||||
},
|
||||
|
||||
"selectPlayerForAudiobook": "Select player for audiobook",
|
||||
"@selectPlayerForAudiobook": {
|
||||
"description": "Hint for selecting player to play audiobook"
|
||||
},
|
||||
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ import 'app_localizations_fr.dart';
|
||||
/// be consistent with the languages listed in the S.supportedLocales
|
||||
/// property.
|
||||
abstract class S {
|
||||
S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
S(String locale)
|
||||
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
|
||||
final String localeName;
|
||||
|
||||
@@ -84,7 +85,8 @@ abstract class S {
|
||||
/// Additional delegates can be added by appending to this list in
|
||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||
/// of delegates is preferred or required.
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
@@ -162,7 +164,7 @@ abstract class S {
|
||||
/// Description for port field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.'**
|
||||
/// **'Leave blank unless using a custom port'**
|
||||
String get portDescription;
|
||||
|
||||
/// Username field label
|
||||
@@ -357,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:
|
||||
@@ -759,12 +797,18 @@ abstract class S {
|
||||
/// **'Album added to queue'**
|
||||
String get albumAddedToQueue;
|
||||
|
||||
/// Confirmation when tracks added
|
||||
/// Snackbar message for tracks added to queue
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tracks added to queue'**
|
||||
String get tracksAddedToQueue;
|
||||
|
||||
/// Confirmation when tracks added
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Track added to queue'**
|
||||
String get trackAddedToQueue;
|
||||
|
||||
/// Play button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1227,6 +1271,30 @@ abstract class S {
|
||||
/// **'Sort by series order'**
|
||||
String get sortBySeriesOrder;
|
||||
|
||||
/// No description provided for @sortByDuration.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sort by duration'**
|
||||
String get sortByDuration;
|
||||
|
||||
/// No description provided for @newestFirst.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Newest first'**
|
||||
String get newestFirst;
|
||||
|
||||
/// No description provided for @oldestFirst.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Oldest first'**
|
||||
String get oldestFirst;
|
||||
|
||||
/// No description provided for @viewDetails.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'View details'**
|
||||
String get viewDetails;
|
||||
|
||||
/// No description provided for @listView.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1335,6 +1403,12 @@ abstract class S {
|
||||
/// **'Choose which rows to display on the home screen'**
|
||||
String get chooseHomeScreenRows;
|
||||
|
||||
/// No description provided for @favorite.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favorite'**
|
||||
String get favorite;
|
||||
|
||||
/// No description provided for @addedToFavorites.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1362,7 +1436,7 @@ abstract class S {
|
||||
/// No description provided for @addToLibrary.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to library'**
|
||||
/// **'Add'**
|
||||
String get addToLibrary;
|
||||
|
||||
/// No description provided for @unknown.
|
||||
@@ -1563,6 +1637,30 @@ abstract class S {
|
||||
/// **'Unknown Library'**
|
||||
String get unknownLibrary;
|
||||
|
||||
/// No description provided for @musicProviders.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Music Providers'**
|
||||
String get musicProviders;
|
||||
|
||||
/// No description provided for @musicProvidersDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which accounts to show in your library'**
|
||||
String get musicProvidersDescription;
|
||||
|
||||
/// No description provided for @libraryRefreshing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refreshing library...'**
|
||||
String get libraryRefreshing;
|
||||
|
||||
/// No description provided for @cannotDisableLastProvider.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cannot disable the last provider'**
|
||||
String get cannotDisableLastProvider;
|
||||
|
||||
/// No description provided for @noSeriesFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1647,12 +1745,24 @@ abstract class S {
|
||||
/// **'Add album to queue on...'**
|
||||
String get addAlbumToQueueOn;
|
||||
|
||||
/// Add item to queue on selected player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to queue'**
|
||||
String get addToQueue;
|
||||
|
||||
/// Bottom sheet title for adding to queue
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to queue on...'**
|
||||
String get addToQueueOn;
|
||||
|
||||
/// Start radio on selected player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start radio'**
|
||||
String get startRadio;
|
||||
|
||||
/// Bottom sheet title for starting radio
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1737,6 +1847,18 @@ abstract class S {
|
||||
/// **'Sort by status (playing, on, off) instead of alphabetically'**
|
||||
String get smartSortPlayersDescription;
|
||||
|
||||
/// Setting to disable automatic player switching
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disable Auto-Switch'**
|
||||
String get disableAutoSwitch;
|
||||
|
||||
/// Description for disable auto-switch setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Never automatically switch to a different player'**
|
||||
String get disableAutoSwitchDescription;
|
||||
|
||||
/// Player state when unavailable
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1797,6 +1919,36 @@ abstract class S {
|
||||
/// **'Display helpful tips for discovering features'**
|
||||
String get showHintsDescription;
|
||||
|
||||
/// Setting for fine volume control
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Volume Precision Mode'**
|
||||
String get volumePrecisionMode;
|
||||
|
||||
/// Description for volume precision mode setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hold still while adjusting volume for fine control'**
|
||||
String get volumePrecisionModeDescription;
|
||||
|
||||
/// Settings section title for display options
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display'**
|
||||
String get display;
|
||||
|
||||
/// Toggle label for showing provider icons on album art
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show Provider Icons'**
|
||||
String get showProviderIcons;
|
||||
|
||||
/// Description for show provider icons toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display provider logos on album art'**
|
||||
String get showProviderIconsDescription;
|
||||
|
||||
/// Hint shown when mini player bounces
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1892,6 +2044,246 @@ abstract class S {
|
||||
/// In en, this message translates to:
|
||||
/// **'Long-press a podcast and tap the heart to add it to favorites'**
|
||||
String get longPressPodcastHint;
|
||||
|
||||
/// Error when playing radio station fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play radio station: {error}'**
|
||||
String failedToPlayRadioStation(String error);
|
||||
|
||||
/// Message when trying to add item already in library
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Item is already in library'**
|
||||
String get itemAlreadyInLibrary;
|
||||
|
||||
/// Error when adding to library fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to add to library'**
|
||||
String get failedToAddToLibrary;
|
||||
|
||||
/// Error when library ID not found for removal
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cannot find library ID for removal'**
|
||||
String get cannotFindLibraryId;
|
||||
|
||||
/// Error when removing from library fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to remove from library'**
|
||||
String get failedToRemoveFromLibrary;
|
||||
|
||||
/// Error when playing album fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play album: {error}'**
|
||||
String failedToPlayAlbum(String error);
|
||||
|
||||
/// Status when playing item on specific player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playing {name} on {playerName}'**
|
||||
String playingOnPlayer(String name, String playerName);
|
||||
|
||||
/// Error when playing playlist fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play playlist: {error}'**
|
||||
String failedToPlayPlaylist(String error);
|
||||
|
||||
/// Error when playing audiobook fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to play audiobook: {error}'**
|
||||
String failedToPlayAudiobook(String error);
|
||||
|
||||
/// Message when no other players available for transfer
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No other players available'**
|
||||
String get noOtherPlayersAvailable;
|
||||
|
||||
/// Tooltip for transfer queue button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer queue'**
|
||||
String get transferQueue;
|
||||
|
||||
/// Tooltip for clear queue button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear queue'**
|
||||
String get clearQueue;
|
||||
|
||||
/// Status when syncing player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Syncing {playerName}...'**
|
||||
String syncingPlayer(String playerName);
|
||||
|
||||
/// Label for switch player action
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch Player'**
|
||||
String get switchPlayer;
|
||||
|
||||
/// Confirmation when logs copied
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Logs copied to clipboard'**
|
||||
String get logsCopiedToClipboard;
|
||||
|
||||
/// Auto option for language selection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto'**
|
||||
String get auto;
|
||||
|
||||
/// Title for transfer queue dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer Queue To'**
|
||||
String get transferQueueTo;
|
||||
|
||||
/// Success message when queue transferred
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Queue transferred to {playerName}'**
|
||||
String queueTransferredTo(String playerName);
|
||||
|
||||
/// Error when queue transfer fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to transfer queue: {error}'**
|
||||
String failedToTransferQueue(String error);
|
||||
|
||||
/// Error when queue loading fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to load queue: {error}'**
|
||||
String failedToLoadQueue(String error);
|
||||
|
||||
/// Label for transfer destination selection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer to...'**
|
||||
String get transferTo;
|
||||
|
||||
/// Tooltip for transfer queue button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer queue to another player'**
|
||||
String get transferQueueToPlayer;
|
||||
|
||||
/// Generic hint for selecting a player in player selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select a player'**
|
||||
String get selectPlayerToPlay;
|
||||
|
||||
/// Hint for selecting player to play album
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to play album'**
|
||||
String get selectPlayerToPlayAlbum;
|
||||
|
||||
/// Hint for selecting player to play playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to play playlist'**
|
||||
String get selectPlayerToPlayPlaylist;
|
||||
|
||||
/// Hint for selecting player to start radio
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player for radio'**
|
||||
String get selectPlayerForRadio;
|
||||
|
||||
/// Hint for selecting player to play episode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player for episode'**
|
||||
String get selectPlayerForEpisode;
|
||||
|
||||
/// Hint for selecting player to play audiobook
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player for audiobook'**
|
||||
String get selectPlayerForAudiobook;
|
||||
|
||||
/// Hint for selecting player to add to queue
|
||||
///
|
||||
/// 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> {
|
||||
@@ -1903,27 +2295,29 @@ class _SDelegate extends LocalizationsDelegate<S> {
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => <String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_SDelegate old) => false;
|
||||
}
|
||||
|
||||
S lookupS(Locale locale) {
|
||||
|
||||
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'de': return SDe();
|
||||
case 'en': return SEn();
|
||||
case 'es': return SEs();
|
||||
case 'fr': return SFr();
|
||||
case 'de':
|
||||
return SDe();
|
||||
case 'en':
|
||||
return SEn();
|
||||
case 'es':
|
||||
return SEs();
|
||||
case 'fr':
|
||||
return SFr();
|
||||
}
|
||||
|
||||
throw FlutterError(
|
||||
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.'
|
||||
);
|
||||
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SDe extends S {
|
||||
String get portHint => 'z.B. 8095 oder leer lassen';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||
String get portDescription =>
|
||||
'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||
|
||||
@override
|
||||
String get username => 'Benutzername';
|
||||
@@ -60,10 +61,12 @@ class SDe extends S {
|
||||
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||
String get authServerUrlHint =>
|
||||
'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||
String get authServerUrlDescription =>
|
||||
'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +83,49 @@ class SDe extends S {
|
||||
String get unencryptedConnection => 'Unverschlusselte Verbindung';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||
String get httpNotEncrypted =>
|
||||
'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Bitte gib deine Music Assistant Serveradresse ein';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Bitte gib Benutzername und Passwort ein';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
String get authFailed =>
|
||||
'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
String get maLoginFailed =>
|
||||
'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
|
||||
String get connectionFailed =>
|
||||
'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Erkenne Authentifizierung...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Keine Authentifizierung';
|
||||
@@ -139,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';
|
||||
|
||||
@@ -221,10 +252,12 @@ class SDe extends S {
|
||||
String get noRadioStations => 'Keine Radiosender';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
|
||||
String get addRadioStationsHint =>
|
||||
'Füge Radiosender in Music Assistant hinzu';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||
String get searchFailed =>
|
||||
'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||
|
||||
@override
|
||||
String get queue => 'Warteschlange';
|
||||
@@ -359,6 +392,9 @@ class SDe extends S {
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
|
||||
|
||||
@override
|
||||
String get trackAddedToQueue => 'Track added to queue';
|
||||
|
||||
@override
|
||||
String get play => 'Abspielen';
|
||||
|
||||
@@ -471,37 +507,43 @@ class SDe extends S {
|
||||
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
|
||||
String get tapHeartAudiobook =>
|
||||
'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'Keine Hörbücher';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
|
||||
String get addAudiobooksHint =>
|
||||
'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'Keine Lieblingskünstler';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
|
||||
String get tapHeartArtist =>
|
||||
'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'Keine Lieblingsalben';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
|
||||
String get tapHeartAlbum =>
|
||||
'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
|
||||
String get tapHeartPlaylist =>
|
||||
'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'Keine Lieblingssongs';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
|
||||
String get longPressTrackHint =>
|
||||
'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Serien laden';
|
||||
@@ -513,7 +555,8 @@ class SDe extends S {
|
||||
String get notConnectedTitle => 'Nicht verbunden';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||
String get connectHint =>
|
||||
'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Server konfigurieren';
|
||||
@@ -549,7 +592,8 @@ class SDe extends S {
|
||||
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||
String get noDebugLogsYet =>
|
||||
'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Debug anzeigen';
|
||||
@@ -624,6 +668,18 @@ class SDe extends S {
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
|
||||
|
||||
@override
|
||||
String get sortByDuration => 'Sort by duration';
|
||||
|
||||
@override
|
||||
String get newestFirst => 'Newest first';
|
||||
|
||||
@override
|
||||
String get oldestFirst => 'Oldest first';
|
||||
|
||||
@override
|
||||
String get viewDetails => 'View details';
|
||||
|
||||
@override
|
||||
String get listView => 'Listenansicht';
|
||||
|
||||
@@ -649,10 +705,12 @@ class SDe extends S {
|
||||
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||
String get showRandomAudiobooks =>
|
||||
'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||
String get showRandomSeries =>
|
||||
'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
|
||||
@@ -667,16 +725,22 @@ class SDe extends S {
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Farben aus Album- und Künstlercover extrahieren';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
|
||||
@@ -691,7 +755,7 @@ class SDe extends S {
|
||||
String get removedFromLibrary => 'Removed from library';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Add to library';
|
||||
String get addToLibrary => 'Add';
|
||||
|
||||
@override
|
||||
String get unknown => 'Unbekannt';
|
||||
@@ -785,17 +849,32 @@ class SDe extends S {
|
||||
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
|
||||
String get theAudioDbApiKeyHint =>
|
||||
'\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Unbekannte Bibliothek';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'Keine Serien gefunden';
|
||||
|
||||
@@ -808,16 +887,19 @@ class SDe extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
|
||||
String get noSeriesAvailable =>
|
||||
'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
|
||||
String get pullToLoadSeries =>
|
||||
'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
|
||||
|
||||
@override
|
||||
String get search => 'Suche';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
|
||||
String get metadataApisDescription =>
|
||||
'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Last.fm API-Schlüssel';
|
||||
@@ -842,9 +924,15 @@ class SDe extends S {
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
|
||||
|
||||
@override
|
||||
String get addToQueue => 'Add to queue';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
|
||||
|
||||
@override
|
||||
String get startRadio => 'Start radio';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return '$name Radio starten auf...';
|
||||
@@ -889,13 +977,22 @@ class SDe extends S {
|
||||
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Intelligente Sortierung';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Nicht verfügbar';
|
||||
@@ -927,7 +1024,25 @@ class SDe extends S {
|
||||
String get showHints => 'Hinweise anzeigen';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||
String get showHintsDescription =>
|
||||
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||
|
||||
@override
|
||||
String get volumePrecisionMode => 'Volume Precision Mode';
|
||||
|
||||
@override
|
||||
String get volumePrecisionModeDescription =>
|
||||
'Hold still while adjusting volume for fine control';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
|
||||
@@ -945,13 +1060,15 @@ class SDe extends S {
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
String get welcomeMessage =>
|
||||
'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -967,17 +1084,160 @@ class SDe extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
String get addTracksHint =>
|
||||
'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
String get longPressRadioHint =>
|
||||
'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,7 @@ class SEn extends S {
|
||||
String get portHint => 'e.g., 8095 or leave blank';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
|
||||
String get portDescription => 'Leave blank unless using a custom port';
|
||||
|
||||
@override
|
||||
String get username => 'Username';
|
||||
@@ -60,10 +60,12 @@ class SEn extends S {
|
||||
String get authServerUrlOptional => 'Auth Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
|
||||
String get authServerUrlHint =>
|
||||
'e.g., auth.example.com (if different from server)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Leave empty if authentication is on the same server';
|
||||
String get authServerUrlDescription =>
|
||||
'Leave empty if authentication is on the same server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,7 +82,8 @@ class SEn extends S {
|
||||
String get unencryptedConnection => 'Unencrypted Connection';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Using HTTP over Tailscale (encrypted tunnel)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
|
||||
@@ -89,31 +92,37 @@ class SEn extends S {
|
||||
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Please enter your Music Assistant server address';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Please enter your name';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Please enter a valid port number (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Please enter username and password';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Authentication failed. Please check your credentials.';
|
||||
String get authFailed =>
|
||||
'Authentication failed. Please check your credentials.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
|
||||
String get maLoginFailed =>
|
||||
'Music Assistant login failed. Please check your credentials.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
|
||||
String get connectionFailed =>
|
||||
'Could not connect to server. Please check the address and try again.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detecting authentication...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Cannot determine authentication requirements. Please check server URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'No Authentication';
|
||||
@@ -139,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';
|
||||
|
||||
@@ -359,6 +387,9 @@ class SEn extends S {
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Tracks added to queue';
|
||||
|
||||
@override
|
||||
String get trackAddedToQueue => 'Track added to queue';
|
||||
|
||||
@override
|
||||
String get play => 'Play';
|
||||
|
||||
@@ -471,37 +502,43 @@ class SEn extends S {
|
||||
String get noFavoriteAudiobooks => 'No favorite audiobooks';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
|
||||
String get tapHeartAudiobook =>
|
||||
'Tap the heart on an audiobook to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'No audiobooks';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Add audiobooks to your library to see them here';
|
||||
String get addAudiobooksHint =>
|
||||
'Add audiobooks to your library to see them here';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'No favorite artists';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites';
|
||||
String get tapHeartArtist =>
|
||||
'Tap the heart on an artist to add them to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'No favorite albums';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites';
|
||||
String get tapHeartAlbum =>
|
||||
'Tap the heart on an album to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'No favorite playlists';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites';
|
||||
String get tapHeartPlaylist =>
|
||||
'Tap the heart on a playlist to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'No favorite tracks';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites';
|
||||
String get longPressTrackHint =>
|
||||
'Long-press a track and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Load Series';
|
||||
@@ -513,7 +550,8 @@ class SEn extends S {
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connect to your Music Assistant server to start listening';
|
||||
String get connectHint =>
|
||||
'Connect to your Music Assistant server to start listening';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configure Server';
|
||||
@@ -624,6 +662,18 @@ class SEn extends S {
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Sort by series order';
|
||||
|
||||
@override
|
||||
String get sortByDuration => 'Sort by duration';
|
||||
|
||||
@override
|
||||
String get newestFirst => 'Newest first';
|
||||
|
||||
@override
|
||||
String get oldestFirst => 'Oldest first';
|
||||
|
||||
@override
|
||||
String get viewDetails => 'View details';
|
||||
|
||||
@override
|
||||
String get listView => 'List view';
|
||||
|
||||
@@ -667,16 +717,22 @@ class SEn extends S {
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extract colors from album and artist artwork';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choose which rows to display on the home screen';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Added to favorites';
|
||||
@@ -691,7 +747,7 @@ class SEn extends S {
|
||||
String get removedFromLibrary => 'Removed from library';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Add to library';
|
||||
String get addToLibrary => 'Add';
|
||||
|
||||
@override
|
||||
String get unknown => 'Unknown';
|
||||
@@ -791,11 +847,25 @@ class SEn extends S {
|
||||
String get audiobookLibraries => 'Audiobook Libraries';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Choose which Audiobookshelf libraries to include';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Unknown Library';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'No Series Found';
|
||||
|
||||
@@ -808,16 +878,19 @@ class SEn extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
|
||||
String get noSeriesAvailable =>
|
||||
'No series available from your audiobook library.\nPull to refresh.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
|
||||
String get pullToLoadSeries =>
|
||||
'Pull down to load series\nfrom Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Search';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
|
||||
String get metadataApisDescription =>
|
||||
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Last.fm API Key';
|
||||
@@ -842,9 +915,15 @@ class SEn extends S {
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Add album to queue on...';
|
||||
|
||||
@override
|
||||
String get addToQueue => 'Add to queue';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Add to queue on...';
|
||||
|
||||
@override
|
||||
String get startRadio => 'Start radio';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Start $name radio on...';
|
||||
@@ -889,13 +968,22 @@ class SEn extends S {
|
||||
String get preferLocalPlayer => 'Prefer Local Player';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Always select this device first when available';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Always select this device first when available';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Smart Sort';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Sort by status (playing, on, off) instead of alphabetically';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Unavailable';
|
||||
@@ -927,7 +1015,25 @@ class SEn extends S {
|
||||
String get showHints => 'Show Hints';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Display helpful tips for discovering features';
|
||||
String get showHintsDescription =>
|
||||
'Display helpful tips for discovering features';
|
||||
|
||||
@override
|
||||
String get volumePrecisionMode => 'Volume Precision Mode';
|
||||
|
||||
@override
|
||||
String get volumePrecisionModeDescription =>
|
||||
'Hold still while adjusting volume for fine control';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Pull to select players';
|
||||
@@ -945,13 +1051,15 @@ class SEn extends S {
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
String get welcomeMessage =>
|
||||
'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -967,17 +1075,160 @@ class SEn extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
String get addTracksHint =>
|
||||
'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
String get longPressRadioHint =>
|
||||
'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SEs extends S {
|
||||
String get portHint => 'ej., 8095 o dejar en blanco';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
|
||||
String get portDescription =>
|
||||
'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
|
||||
|
||||
@override
|
||||
String get username => 'Usuario';
|
||||
@@ -57,13 +58,16 @@ class SEs extends S {
|
||||
String get disconnect => 'Desconectar';
|
||||
|
||||
@override
|
||||
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
|
||||
String get authServerUrlOptional =>
|
||||
'URL del servidor de autenticación (Opcional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
|
||||
String get authServerUrlHint =>
|
||||
'ej., auth.example.com (si es diferente del servidor)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor';
|
||||
String get authServerUrlDescription =>
|
||||
'Dejar vacío si la autenticación está en el mismo servidor';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +84,49 @@ class SEs extends S {
|
||||
String get unencryptedConnection => 'Conexión sin cifrar';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
|
||||
String get httpsFailedUsingHttp =>
|
||||
'HTTPS falló, usando HTTP como alternativa';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Por favor, introduce la dirección de tu servidor Music Assistant';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Por favor, introduce tu nombre';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Por favor, introduce un número de puerto válido (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Por favor, introduce usuario y contraseña';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||
String get authFailed =>
|
||||
'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
|
||||
String get maLoginFailed =>
|
||||
'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
|
||||
String get connectionFailed =>
|
||||
'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detectando autenticación...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
|
||||
String get cannotDetermineAuth =>
|
||||
'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Sin autenticación';
|
||||
@@ -139,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';
|
||||
|
||||
@@ -221,10 +253,12 @@ class SEs extends S {
|
||||
String get noRadioStations => 'No hay emisoras de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
|
||||
String get addRadioStationsHint =>
|
||||
'Añade emisoras de radio en Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||
String get searchFailed =>
|
||||
'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||
|
||||
@override
|
||||
String get queue => 'Cola';
|
||||
@@ -264,7 +298,8 @@ class SEs extends S {
|
||||
String get system => 'Sistema';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||
String get pullToRefresh =>
|
||||
'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Ver registros de depuración';
|
||||
@@ -301,7 +336,8 @@ class SEs extends S {
|
||||
String get logsCleared => 'Registros borrados';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
|
||||
String get playerListCopied =>
|
||||
'Lista de reproductores copiada al portapapeles';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Aún no hay registros';
|
||||
@@ -359,6 +395,9 @@ class SEs extends S {
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
|
||||
|
||||
@override
|
||||
String get trackAddedToQueue => 'Track added to queue';
|
||||
|
||||
@override
|
||||
String get play => 'Reproducir';
|
||||
|
||||
@@ -471,37 +510,43 @@ class SEs extends S {
|
||||
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
|
||||
String get tapHeartAudiobook =>
|
||||
'Toca el corazón en un audiolibro para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'No hay audiolibros';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí';
|
||||
String get addAudiobooksHint =>
|
||||
'Añade audiolibros a tu biblioteca para verlos aquí';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'No hay artistas favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos';
|
||||
String get tapHeartArtist =>
|
||||
'Toca el corazón en un artista para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'No hay álbumes favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos';
|
||||
String get tapHeartAlbum =>
|
||||
'Toca el corazón en un álbum para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
|
||||
String get tapHeartPlaylist =>
|
||||
'Toca el corazón en una lista para añadirla a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'No hay canciones favoritas';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
|
||||
String get longPressTrackHint =>
|
||||
'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Cargar series';
|
||||
@@ -513,7 +558,8 @@ class SEs extends S {
|
||||
String get notConnectedTitle => 'No conectado';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||
String get connectHint =>
|
||||
'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurar servidor';
|
||||
@@ -549,7 +595,8 @@ class SEs extends S {
|
||||
String get noLogsToCopy => 'No hay registros para copiar';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||
String get noDebugLogsYet =>
|
||||
'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Mostrar depuración';
|
||||
@@ -564,7 +611,8 @@ class SEs extends S {
|
||||
String get noChapters => 'Sin capítulos';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
|
||||
String get noChapterInfo =>
|
||||
'Este audiolibro no tiene información de capítulos';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
@@ -624,6 +672,18 @@ class SEs extends S {
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Ordenar por orden de serie';
|
||||
|
||||
@override
|
||||
String get sortByDuration => 'Sort by duration';
|
||||
|
||||
@override
|
||||
String get newestFirst => 'Newest first';
|
||||
|
||||
@override
|
||||
String get oldestFirst => 'Oldest first';
|
||||
|
||||
@override
|
||||
String get viewDetails => 'View details';
|
||||
|
||||
@override
|
||||
String get listView => 'Vista de lista';
|
||||
|
||||
@@ -637,7 +697,8 @@ class SEs extends S {
|
||||
String get artist => 'Artista';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
|
||||
String get showRecentlyPlayedAlbums =>
|
||||
'Mostrar álbumes reproducidos recientemente';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
|
||||
@@ -649,34 +710,44 @@ class SEs extends S {
|
||||
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
|
||||
String get showRandomAudiobooks =>
|
||||
'Mostrar audiolibros aleatorios para descubrir';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
|
||||
String get showRandomSeries =>
|
||||
'Mostrar series de audiolibros aleatorias para descubrir';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
|
||||
String get showFavoriteArtists =>
|
||||
'Mostrar una fila de tus artistas favoritos';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
|
||||
String get showFavoriteTracks =>
|
||||
'Mostrar una fila de tus canciones favoritas';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extraer colores de las portadas de álbumes y artistas';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Elige qué filas mostrar en la pantalla de inicio';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Añadido a favoritos';
|
||||
@@ -691,7 +762,7 @@ class SEs extends S {
|
||||
String get removedFromLibrary => 'Removed from library';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Add to library';
|
||||
String get addToLibrary => 'Add';
|
||||
|
||||
@override
|
||||
String get unknown => 'Desconocido';
|
||||
@@ -774,7 +845,8 @@ class SEs extends S {
|
||||
String get loadingChapters => 'Cargando capítulos...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
|
||||
String get noChapterInfoAvailable =>
|
||||
'No hay información de capítulos disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
@@ -785,17 +857,32 @@ class SEs extends S {
|
||||
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
|
||||
String get theAudioDbApiKeyHint =>
|
||||
'Usa \"2\" para nivel gratuito o clave premium';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Bibliotecas de audiolibros';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Elige qué bibliotecas de Audiobookshelf incluir';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Biblioteca desconocida';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'No se encontraron series';
|
||||
|
||||
@@ -808,16 +895,19 @@ class SEs extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
|
||||
String get noSeriesAvailable =>
|
||||
'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant';
|
||||
String get pullToLoadSeries =>
|
||||
'Desliza hacia abajo para cargar series\ndesde Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Buscar';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
|
||||
String get metadataApisDescription =>
|
||||
'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Clave API de Last.fm';
|
||||
@@ -842,9 +932,15 @@ class SEs extends S {
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
|
||||
|
||||
@override
|
||||
String get addToQueue => 'Add to queue';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Añadir a la cola en...';
|
||||
|
||||
@override
|
||||
String get startRadio => 'Start radio';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Iniciar radio de $name en...';
|
||||
@@ -889,13 +985,22 @@ class SEs extends S {
|
||||
String get preferLocalPlayer => 'Preferir dispositivo local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Orden inteligente';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'No disponible';
|
||||
@@ -927,7 +1032,25 @@ class SEs extends S {
|
||||
String get showHints => 'Mostrar Sugerencias';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
|
||||
String get showHintsDescription =>
|
||||
'Mostrar consejos útiles para descubrir funciones';
|
||||
|
||||
@override
|
||||
String get volumePrecisionMode => 'Volume Precision Mode';
|
||||
|
||||
@override
|
||||
String get volumePrecisionModeDescription =>
|
||||
'Hold still while adjusting volume for fine control';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
|
||||
@@ -945,13 +1068,15 @@ class SEs extends S {
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
String get welcomeMessage =>
|
||||
'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -967,17 +1092,160 @@ class SEs extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
String get addTracksHint =>
|
||||
'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
String get longPressRadioHint =>
|
||||
'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SFr extends S {
|
||||
String get portHint => 'ex., 8095 ou laisser vide';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
|
||||
String get portDescription =>
|
||||
'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
|
||||
|
||||
@override
|
||||
String get username => 'Nom d\'utilisateur';
|
||||
@@ -60,10 +61,12 @@ class SFr extends S {
|
||||
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
|
||||
String get authServerUrlHint =>
|
||||
'ex., auth.example.com (si different du serveur)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
|
||||
String get authServerUrlDescription =>
|
||||
'Laisser vide si l\'authentification est sur le meme serveur';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +83,50 @@ class SFr extends S {
|
||||
String get unencryptedConnection => 'Connexion non chiffrée';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
|
||||
String get httpsFailedUsingHttp =>
|
||||
'HTTPS a échoué, utilisation de HTTP en repli';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
|
||||
String get httpNotEncrypted =>
|
||||
'Connexion HTTP - les donnees ne sont pas chiffrées';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Veuillez entrer l\'adresse de votre serveur Music Assistant';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Veuillez entrer votre nom';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Veuillez entrer un numero de port valide (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Veuillez entrer le nom d\'utilisateur et le mot de passe';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
||||
String get authFailed =>
|
||||
'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
|
||||
String get maLoginFailed =>
|
||||
'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
|
||||
String get connectionFailed =>
|
||||
'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detection de l\'authentification...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Pas d\'authentification';
|
||||
@@ -128,7 +141,8 @@ class SFr extends S {
|
||||
String get musicAssistantLogin => 'Connexion Music Assistant';
|
||||
|
||||
@override
|
||||
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
|
||||
String get pressBackToMinimize =>
|
||||
'Appuyez a nouveau sur retour pour minimiser';
|
||||
|
||||
@override
|
||||
String get recentlyPlayed => 'Ecoutes recemment';
|
||||
@@ -139,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';
|
||||
|
||||
@@ -221,10 +254,12 @@ class SFr extends S {
|
||||
String get noRadioStations => 'Aucune station de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
|
||||
String get addRadioStationsHint =>
|
||||
'Ajouter des stations de radio dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||
String get searchFailed =>
|
||||
'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||
|
||||
@override
|
||||
String get queue => 'File d\'attente';
|
||||
@@ -264,7 +299,8 @@ class SFr extends S {
|
||||
String get system => 'Système';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||
String get pullToRefresh =>
|
||||
'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Voir les journaux de debogage';
|
||||
@@ -301,7 +337,8 @@ class SFr extends S {
|
||||
String get logsCleared => 'Journaux effaces';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
|
||||
String get playerListCopied =>
|
||||
'Liste des lecteurs copiee dans le presse-papiers !';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Pas encore de journaux';
|
||||
@@ -359,6 +396,9 @@ class SFr extends S {
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
|
||||
|
||||
@override
|
||||
String get trackAddedToQueue => 'Track added to queue';
|
||||
|
||||
@override
|
||||
String get play => 'Lire';
|
||||
|
||||
@@ -471,37 +511,43 @@ class SFr extends S {
|
||||
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
|
||||
String get tapHeartAudiobook =>
|
||||
'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'Aucun livre audio';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
|
||||
String get addAudiobooksHint =>
|
||||
'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'Aucun artiste favori';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
|
||||
String get tapHeartArtist =>
|
||||
'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'Aucun album favori';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
|
||||
String get tapHeartAlbum =>
|
||||
'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'Aucune playlist favorite';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
|
||||
String get tapHeartPlaylist =>
|
||||
'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'Aucune piste favorite';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
|
||||
String get longPressTrackHint =>
|
||||
'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Charger les séries';
|
||||
@@ -513,7 +559,8 @@ class SFr extends S {
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||
String get connectHint =>
|
||||
'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurer le serveur';
|
||||
@@ -549,7 +596,8 @@ class SFr extends S {
|
||||
String get noLogsToCopy => 'Aucun journal a copier';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||
String get noDebugLogsYet =>
|
||||
'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Afficher le debogage';
|
||||
@@ -564,7 +612,8 @@ class SFr extends S {
|
||||
String get noChapters => 'Aucun chapitre';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||
String get noChapterInfo =>
|
||||
'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
@@ -624,6 +673,18 @@ class SFr extends S {
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Trier par ordre de série';
|
||||
|
||||
@override
|
||||
String get sortByDuration => 'Sort by duration';
|
||||
|
||||
@override
|
||||
String get newestFirst => 'Newest first';
|
||||
|
||||
@override
|
||||
String get oldestFirst => 'Oldest first';
|
||||
|
||||
@override
|
||||
String get viewDetails => 'View details';
|
||||
|
||||
@override
|
||||
String get listView => 'Vue en liste';
|
||||
|
||||
@@ -637,10 +698,12 @@ class SFr extends S {
|
||||
String get artist => 'Artiste';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
|
||||
String get showRecentlyPlayedAlbums =>
|
||||
'Afficher les albums écoutes recemment';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
|
||||
String get showRandomArtists =>
|
||||
'Afficher des artistes aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
|
||||
@@ -649,34 +712,46 @@ class SFr extends S {
|
||||
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
|
||||
String get showRandomAudiobooks =>
|
||||
'Afficher des livres audio aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir';
|
||||
String get showRandomSeries =>
|
||||
'Afficher des séries de livres audio aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
|
||||
String get showFavoriteArtists =>
|
||||
'Afficher une rangee de vos artistes favoris';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
|
||||
String get showFavoriteTracks =>
|
||||
'Afficher une rangee de vos pistes favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
|
||||
String get showFavoritePlaylists =>
|
||||
'Afficher une rangee de vos playlists favorites';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Afficher une rangee de vos stations de radio favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
|
||||
String get showFavoritePodcasts =>
|
||||
'Afficher une rangee de vos podcasts favoris';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extraire les couleurs des pochettes d\'albums et d\'artistes';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Ajoute aux favoris';
|
||||
@@ -721,13 +796,15 @@ class SFr extends S {
|
||||
String get podcasts => 'Podcasts';
|
||||
|
||||
@override
|
||||
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
|
||||
String get podcastSupportComingSoon =>
|
||||
'Prise en charge des podcasts bientot disponible';
|
||||
|
||||
@override
|
||||
String get noPodcasts => 'Aucun podcast';
|
||||
|
||||
@override
|
||||
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
|
||||
String get addPodcastsHint =>
|
||||
'Abonnez-vous a des podcasts dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
@@ -774,7 +851,8 @@ class SFr extends S {
|
||||
String get loadingChapters => 'Chargement des chapitres...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
|
||||
String get noChapterInfoAvailable =>
|
||||
'Aucune information de chapitre disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
@@ -785,17 +863,32 @@ class SFr extends S {
|
||||
String get theAudioDbApiKey => 'Cle API TheAudioDB';
|
||||
|
||||
@override
|
||||
String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
|
||||
String get theAudioDbApiKeyHint =>
|
||||
'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
|
||||
|
||||
@override
|
||||
String get audiobookLibraries => 'Bibliothèques de livres audio';
|
||||
|
||||
@override
|
||||
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
|
||||
String get chooseAudiobookLibraries =>
|
||||
'Choisir quelles bibliothèques Audiobookshelf inclure';
|
||||
|
||||
@override
|
||||
String get unknownLibrary => 'Bibliothèque inconnue';
|
||||
|
||||
@override
|
||||
String get musicProviders => 'Music Providers';
|
||||
|
||||
@override
|
||||
String get musicProvidersDescription =>
|
||||
'Choose which accounts to show in your library';
|
||||
|
||||
@override
|
||||
String get libraryRefreshing => 'Refreshing library...';
|
||||
|
||||
@override
|
||||
String get cannotDisableLastProvider => 'Cannot disable the last provider';
|
||||
|
||||
@override
|
||||
String get noSeriesFound => 'Aucune série trouvee';
|
||||
|
||||
@@ -808,16 +901,19 @@ class SFr extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
|
||||
String get noSeriesAvailable =>
|
||||
'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
|
||||
String get pullToLoadSeries =>
|
||||
'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Rechercher';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
|
||||
String get metadataApisDescription =>
|
||||
'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Cle API Last.fm';
|
||||
@@ -840,11 +936,18 @@ class SFr extends S {
|
||||
String get playOn => 'Lire sur...';
|
||||
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
|
||||
String get addAlbumToQueueOn =>
|
||||
'Ajouter l\'album a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String get addToQueue => 'Add to queue';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String get startRadio => 'Start radio';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Demarrer la radio $name sur...';
|
||||
@@ -877,7 +980,8 @@ class SFr extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
|
||||
String get materialYouDescription =>
|
||||
'Utiliser les couleurs systeme (Android 12+)';
|
||||
|
||||
@override
|
||||
String get accentColor => 'Couleur d\'accentuation';
|
||||
@@ -889,13 +993,22 @@ class SFr extends S {
|
||||
String get preferLocalPlayer => 'Préférer le lecteur local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Toujours sélectionner cet appareil en premier si disponible';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Tri intelligent';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Indisponible';
|
||||
@@ -927,7 +1040,25 @@ class SFr extends S {
|
||||
String get showHints => 'Afficher les astuces';
|
||||
|
||||
@override
|
||||
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||
String get showHintsDescription =>
|
||||
'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||
|
||||
@override
|
||||
String get volumePrecisionMode => 'Volume Precision Mode';
|
||||
|
||||
@override
|
||||
String get volumePrecisionModeDescription =>
|
||||
'Hold still while adjusting volume for fine control';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get showProviderIcons => 'Show Provider Icons';
|
||||
|
||||
@override
|
||||
String get showProviderIconsDescription =>
|
||||
'Display provider logos on album art';
|
||||
|
||||
@override
|
||||
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
|
||||
@@ -939,19 +1070,22 @@ class SFr extends S {
|
||||
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
|
||||
|
||||
@override
|
||||
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
|
||||
String get selectPlayerHint =>
|
||||
'Choisissez un lecteur ou fermez en balayant vers le bas';
|
||||
|
||||
@override
|
||||
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
|
||||
String get welcomeMessage =>
|
||||
'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
|
||||
|
||||
@override
|
||||
String get skip => 'Passer';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
|
||||
String get dismissPlayerHint =>
|
||||
'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -967,17 +1101,160 @@ class SFr extends S {
|
||||
String get noTracks => 'No tracks';
|
||||
|
||||
@override
|
||||
String get addTracksHint => 'Add some music to your library to see tracks here';
|
||||
String get addTracksHint =>
|
||||
'Add some music to your library to see tracks here';
|
||||
|
||||
@override
|
||||
String get noFavoriteRadioStations => 'No favorite radio stations';
|
||||
|
||||
@override
|
||||
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
|
||||
String get longPressRadioHint =>
|
||||
'Long-press a station and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePodcasts => 'No favorite podcasts';
|
||||
|
||||
@override
|
||||
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
|
||||
String get longPressPodcastHint =>
|
||||
'Long-press a podcast and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String failedToPlayRadioStation(String error) {
|
||||
return 'Failed to play radio station: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get itemAlreadyInLibrary => 'Item is already in library';
|
||||
|
||||
@override
|
||||
String get failedToAddToLibrary => 'Failed to add to library';
|
||||
|
||||
@override
|
||||
String get cannotFindLibraryId => 'Cannot find library ID for removal';
|
||||
|
||||
@override
|
||||
String get failedToRemoveFromLibrary => 'Failed to remove from library';
|
||||
|
||||
@override
|
||||
String failedToPlayAlbum(String error) {
|
||||
return 'Failed to play album: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String playingOnPlayer(String name, String playerName) {
|
||||
return 'Playing $name on $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayPlaylist(String error) {
|
||||
return 'Failed to play playlist: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToPlayAudiobook(String error) {
|
||||
return 'Failed to play audiobook: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noOtherPlayersAvailable => 'No other players available';
|
||||
|
||||
@override
|
||||
String get transferQueue => 'Transfer queue';
|
||||
|
||||
@override
|
||||
String get clearQueue => 'Clear queue';
|
||||
|
||||
@override
|
||||
String syncingPlayer(String playerName) {
|
||||
return 'Syncing $playerName...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get switchPlayer => 'Switch Player';
|
||||
|
||||
@override
|
||||
String get logsCopiedToClipboard => 'Logs copied to clipboard';
|
||||
|
||||
@override
|
||||
String get auto => 'Auto';
|
||||
|
||||
@override
|
||||
String get transferQueueTo => 'Transfer Queue To';
|
||||
|
||||
@override
|
||||
String queueTransferredTo(String playerName) {
|
||||
return 'Queue transferred to $playerName';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToTransferQueue(String error) {
|
||||
return 'Failed to transfer queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String failedToLoadQueue(String error) {
|
||||
return 'Failed to load queue: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get transferTo => 'Transfer to...';
|
||||
|
||||
@override
|
||||
String get transferQueueToPlayer => 'Transfer queue to another player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlay => 'Select a player';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayAlbum => 'Select player to play album';
|
||||
|
||||
@override
|
||||
String get selectPlayerToPlayPlaylist => 'Select player to play playlist';
|
||||
|
||||
@override
|
||||
String get selectPlayerForRadio => 'Select player for radio';
|
||||
|
||||
@override
|
||||
String get selectPlayerForEpisode => 'Select player for episode';
|
||||
|
||||
@override
|
||||
String get selectPlayerForAudiobook => 'Select player for audiobook';
|
||||
|
||||
@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';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -223,8 +275,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
}
|
||||
|
||||
// Then check expanded player
|
||||
// No haptic - Android back gesture provides its own
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
@@ -328,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(),
|
||||
),
|
||||
@@ -474,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(() {
|
||||
@@ -482,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;
|
||||
});
|
||||
|
||||
@@ -16,6 +16,10 @@ class ProviderMapping {
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
/// Whether this item is in the library for this specific provider instance.
|
||||
/// True = this provider "owns" the item (user added it from this account)
|
||||
/// False = item exists but wasn't added from this provider
|
||||
final bool inLibrary;
|
||||
|
||||
ProviderMapping({
|
||||
required this.itemId,
|
||||
@@ -23,6 +27,7 @@ class ProviderMapping {
|
||||
required this.providerInstance,
|
||||
required this.available,
|
||||
this.audioFormat,
|
||||
this.inLibrary = true,
|
||||
});
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
@@ -32,6 +37,8 @@ class ProviderMapping {
|
||||
providerInstance: json['provider_instance'] as String? ?? '',
|
||||
available: json['available'] as bool? ?? true,
|
||||
audioFormat: json['audio_format'] as Map<String, dynamic>?,
|
||||
// Parse in_library field - MA uses 1/0 or true/false
|
||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +49,7 @@ class ProviderMapping {
|
||||
'provider_instance': providerInstance,
|
||||
'available': available,
|
||||
if (audioFormat != null) 'audio_format': audioFormat,
|
||||
'in_library': inLibrary,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -58,10 +66,6 @@ class MediaItem {
|
||||
final bool? favorite;
|
||||
final int? position;
|
||||
final Duration? duration;
|
||||
final DateTime? timestampAdded;
|
||||
final DateTime? timestampModified;
|
||||
final DateTime? lastPlayed;
|
||||
final int? playCount;
|
||||
|
||||
MediaItem({
|
||||
required this.itemId,
|
||||
@@ -75,10 +79,6 @@ class MediaItem {
|
||||
this.favorite,
|
||||
this.position,
|
||||
this.duration,
|
||||
this.timestampAdded,
|
||||
this.timestampModified,
|
||||
this.lastPlayed,
|
||||
this.playCount,
|
||||
});
|
||||
|
||||
factory MediaItem.fromJson(Map<String, dynamic> json) {
|
||||
@@ -91,21 +91,6 @@ class MediaItem {
|
||||
);
|
||||
}
|
||||
|
||||
// Parse timestamp fields (can be int unix timestamp or ISO string)
|
||||
DateTime? parseTimestamp(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value * 1000);
|
||||
}
|
||||
if (value is double) {
|
||||
return DateTime.fromMillisecondsSinceEpoch((value * 1000).toInt());
|
||||
}
|
||||
if (value is String) {
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return MediaItem(
|
||||
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
|
||||
provider: json['provider'] as String? ?? 'unknown',
|
||||
@@ -122,10 +107,6 @@ class MediaItem {
|
||||
duration: json['duration'] != null
|
||||
? Duration(seconds: (json['duration'] as num).toInt())
|
||||
: null,
|
||||
timestampAdded: parseTimestamp(json['timestamp_added']),
|
||||
timestampModified: parseTimestamp(json['timestamp_modified']),
|
||||
lastPlayed: parseTimestamp(json['last_played']),
|
||||
playCount: json['play_count'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,12 +124,25 @@ class MediaItem {
|
||||
if (favorite != null) 'favorite': favorite,
|
||||
if (position != null) 'position': position,
|
||||
if (duration != null) 'duration': duration!.inSeconds,
|
||||
if (timestampAdded != null) 'timestamp_added': timestampAdded!.millisecondsSinceEpoch ~/ 1000,
|
||||
if (timestampModified != null) 'timestamp_modified': timestampModified!.millisecondsSinceEpoch ~/ 1000,
|
||||
if (lastPlayed != null) 'last_played': lastPlayed!.millisecondsSinceEpoch ~/ 1000,
|
||||
if (playCount != null) 'play_count': playCount,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MediaItem &&
|
||||
runtimeType == other.runtimeType &&
|
||||
itemId == other.itemId &&
|
||||
provider == other.provider;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(itemId, provider);
|
||||
|
||||
/// Check if this item is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
class Artist extends MediaItem {
|
||||
@@ -161,10 +155,6 @@ class Artist extends MediaItem {
|
||||
super.providerMappings,
|
||||
super.metadata,
|
||||
super.favorite,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.artist);
|
||||
|
||||
factory Artist.fromJson(Map<String, dynamic> json) {
|
||||
@@ -178,13 +168,15 @@ class Artist extends MediaItem {
|
||||
providerMappings: item.providerMappings,
|
||||
metadata: item.metadata,
|
||||
favorite: item.favorite,
|
||||
timestampAdded: item.timestampAdded,
|
||||
timestampModified: item.timestampModified,
|
||||
lastPlayed: item.lastPlayed,
|
||||
playCount: item.playCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this artist is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => super.toJson();
|
||||
}
|
||||
@@ -206,10 +198,6 @@ class Album extends MediaItem {
|
||||
super.providerMappings,
|
||||
super.metadata,
|
||||
super.favorite,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.album);
|
||||
|
||||
factory Album.fromJson(Map<String, dynamic> json) {
|
||||
@@ -237,10 +225,6 @@ class Album extends MediaItem {
|
||||
providerMappings: item.providerMappings,
|
||||
metadata: item.metadata,
|
||||
favorite: item.favorite,
|
||||
timestampAdded: item.timestampAdded,
|
||||
timestampModified: item.timestampModified,
|
||||
lastPlayed: item.lastPlayed,
|
||||
playCount: item.playCount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -287,10 +271,6 @@ class Track extends MediaItem {
|
||||
super.favorite,
|
||||
super.position,
|
||||
super.duration,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.track);
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) {
|
||||
@@ -312,16 +292,18 @@ class Track extends MediaItem {
|
||||
favorite: item.favorite,
|
||||
position: item.position,
|
||||
duration: item.duration,
|
||||
timestampAdded: item.timestampAdded,
|
||||
timestampModified: item.timestampModified,
|
||||
lastPlayed: item.lastPlayed,
|
||||
playCount: item.playCount,
|
||||
);
|
||||
}
|
||||
|
||||
String get artistsString =>
|
||||
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
|
||||
|
||||
/// Check if this track is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = super.toJson();
|
||||
@@ -350,10 +332,6 @@ class Playlist extends MediaItem {
|
||||
super.providerMappings,
|
||||
super.metadata,
|
||||
super.favorite,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.playlist);
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> json) {
|
||||
@@ -370,12 +348,14 @@ class Playlist extends MediaItem {
|
||||
providerMappings: item.providerMappings,
|
||||
metadata: item.metadata,
|
||||
favorite: item.favorite,
|
||||
timestampAdded: item.timestampAdded,
|
||||
timestampModified: item.timestampModified,
|
||||
lastPlayed: item.lastPlayed,
|
||||
playCount: item.playCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this playlist is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
class Chapter {
|
||||
@@ -484,9 +464,20 @@ class Audiobook extends MediaItem {
|
||||
factory Audiobook.fromJson(Map<String, dynamic> json) {
|
||||
final item = MediaItem.fromJson(json);
|
||||
|
||||
// Parse year
|
||||
// Parse year - check top level first, then metadata.release_date (ABS format)
|
||||
int? year;
|
||||
final yearValue = json['year'];
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
dynamic yearValue = json['year'];
|
||||
if (yearValue == null && metadata != null) {
|
||||
// ABS stores year in metadata.release_date (can be "2024" or "2024-01-15")
|
||||
final releaseDate = metadata['release_date'];
|
||||
if (releaseDate is String && releaseDate.isNotEmpty) {
|
||||
// Extract year from date string (handles both "2024" and "2024-01-15" formats)
|
||||
yearValue = releaseDate.split('-').first;
|
||||
} else if (releaseDate is int) {
|
||||
yearValue = releaseDate;
|
||||
}
|
||||
}
|
||||
if (yearValue is int) {
|
||||
year = yearValue;
|
||||
} else if (yearValue is String) {
|
||||
@@ -516,8 +507,6 @@ class Audiobook extends MediaItem {
|
||||
|
||||
// Parse chapters - check top level first, then metadata
|
||||
List<Chapter>? chapters;
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
|
||||
dynamic chaptersData = json['chapters'];
|
||||
if (chaptersData == null && metadata != null) {
|
||||
chaptersData = metadata['chapters'];
|
||||
@@ -563,6 +552,12 @@ class Audiobook extends MediaItem {
|
||||
String get narratorsString =>
|
||||
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
|
||||
|
||||
/// Check if this audiobook is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
/// Get progress as a percentage (0.0 to 1.0)
|
||||
double get progress {
|
||||
if (fullyPlayed == true) return 1.0;
|
||||
|
||||
@@ -17,6 +17,7 @@ class Player {
|
||||
final String? activeSource; // The currently active source for this player
|
||||
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
|
||||
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
|
||||
final String? activeQueue; // The active_queue from MA - has value when MA is controlling playback
|
||||
|
||||
Player({
|
||||
required this.playerId,
|
||||
@@ -35,6 +36,7 @@ class Player {
|
||||
this.activeSource,
|
||||
this.isExternalSource = false,
|
||||
this.appId,
|
||||
this.activeQueue,
|
||||
});
|
||||
|
||||
/// Create a copy of this Player with some fields replaced
|
||||
@@ -55,6 +57,7 @@ class Player {
|
||||
String? activeSource,
|
||||
bool? isExternalSource,
|
||||
String? appId,
|
||||
String? activeQueue,
|
||||
}) {
|
||||
return Player(
|
||||
playerId: playerId ?? this.playerId,
|
||||
@@ -73,6 +76,7 @@ class Player {
|
||||
activeSource: activeSource ?? this.activeSource,
|
||||
isExternalSource: isExternalSource ?? this.isExternalSource,
|
||||
appId: appId ?? this.appId,
|
||||
activeQueue: activeQueue ?? this.activeQueue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,50 +222,61 @@ class Player {
|
||||
// Parse active_source - the currently active source for this player
|
||||
final activeSource = json['active_source'] as String?;
|
||||
|
||||
// Detect external source (optical, Spotify, AirPlay, etc.)
|
||||
// Primary indicator: app_id - if not 'music_assistant', it's an external source
|
||||
// Secondary indicators: URI patterns and media_type
|
||||
// Parse active_queue - this is the key indicator for MA-controlled playback
|
||||
// When MA is playing: active_queue = 'uuid:...' or similar queue ID
|
||||
// When external source: active_queue = null
|
||||
final activeQueue = json['active_queue'] as String?;
|
||||
|
||||
// Parse app_id - helps distinguish external sources on DLNA players
|
||||
final appId = json['app_id'] as String?;
|
||||
|
||||
// Detect external source (optical, Spotify Connect, AirPlay, etc.)
|
||||
//
|
||||
// Detection logic priority:
|
||||
// 1. If app_id == 'music_assistant' -> MA is playing, NOT external
|
||||
// 2. If active_queue has a value -> MA has a queue, NOT external
|
||||
// (This handles Spotify as music provider where URI is spotify:// but MA controls playback)
|
||||
// 3. If app_id is set and NOT 'music_assistant' -> external source (e.g., 'http', 'spotify')
|
||||
// 4. Fallback: Check URI patterns for simple external IDs (optical, line_in, etc.)
|
||||
bool isExternalSource = false;
|
||||
|
||||
// Check app_id first - this is the most reliable indicator for DLNA players
|
||||
// When MA is playing: app_id = 'music_assistant'
|
||||
// When external source: app_id = 'http', 'spotify', etc.
|
||||
final appId = json['app_id'] as String?;
|
||||
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
|
||||
// app_id is set but not 'music_assistant' - external source is active
|
||||
// If MA explicitly says it's playing, trust that
|
||||
if (appId == 'music_assistant') {
|
||||
isExternalSource = false;
|
||||
}
|
||||
// If there's an active queue, MA is controlling playback (even with spotify:// URIs)
|
||||
else if (activeQueue != null && activeQueue.isNotEmpty) {
|
||||
isExternalSource = false;
|
||||
}
|
||||
// If app_id is set to something else (http, spotify, etc.), it's external
|
||||
else if (appId != null && appId.isNotEmpty) {
|
||||
isExternalSource = true;
|
||||
}
|
||||
|
||||
// Also check current_media for additional external source indicators
|
||||
if (!isExternalSource && json.containsKey('current_media')) {
|
||||
// Fallback: check current_media for simple external source indicators
|
||||
// Only check these when we have no app_id and no active_queue
|
||||
else if (json.containsKey('current_media')) {
|
||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||
if (currentMedia != null) {
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
|
||||
// External source indicators:
|
||||
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
|
||||
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
|
||||
// 3. Media type is 'unknown' (MA doesn't recognize the source)
|
||||
// Only check for simple external source identifiers (physical inputs)
|
||||
// Do NOT flag spotify://, airplay://, etc. as external here -
|
||||
// those should only be external if app_id indicates external control
|
||||
if (uri != null) {
|
||||
final uriLower = uri.toLowerCase();
|
||||
// Simple external source identifiers (no :// or /)
|
||||
// These are physical inputs that are always external
|
||||
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
|
||||
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
|
||||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
|
||||
uriLower == 'coaxial' || uriLower == 'toslink');
|
||||
// External streaming protocols
|
||||
final isExternalProtocol = uriLower.startsWith('spotify://') ||
|
||||
uriLower.startsWith('airplay://') ||
|
||||
uriLower.startsWith('cast://') ||
|
||||
uriLower.startsWith('bluetooth://');
|
||||
|
||||
isExternalSource = isSimpleExternalId || isExternalProtocol;
|
||||
isExternalSource = isSimpleExternalId;
|
||||
}
|
||||
|
||||
// Also check media_type - 'unknown' often indicates external source
|
||||
// Also check media_type - 'unknown' with non-MA URI indicates external source
|
||||
if (!isExternalSource && mediaType == 'unknown') {
|
||||
// If media_type is unknown and URI doesn't look like MA content, it's external
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
|
||||
isExternalSource = true;
|
||||
@@ -287,6 +302,7 @@ class Player {
|
||||
activeSource: activeSource,
|
||||
isExternalSource: isExternalSource,
|
||||
appId: appId,
|
||||
activeQueue: activeQueue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,6 +324,7 @@ class Player {
|
||||
if (activeSource != null) 'active_source': activeSource,
|
||||
'is_external_source': isExternalSource,
|
||||
if (appId != null) 'app_id': appId,
|
||||
if (activeQueue != null) 'active_queue': activeQueue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/// Represents a music provider instance from Music Assistant.
|
||||
///
|
||||
/// Each provider (like Spotify, Tidal) can have multiple instances
|
||||
/// (e.g., two different Spotify accounts). This class represents
|
||||
/// a single provider instance with its unique ID.
|
||||
class ProviderInstance {
|
||||
/// Unique identifier for this provider instance (e.g., "spotify--abc123")
|
||||
final String instanceId;
|
||||
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this instance (e.g., "Spotify (John's Account)")
|
||||
final String name;
|
||||
|
||||
/// Whether this provider is currently available/connected
|
||||
final bool available;
|
||||
|
||||
ProviderInstance({
|
||||
required this.instanceId,
|
||||
required this.domain,
|
||||
required this.name,
|
||||
required this.available,
|
||||
});
|
||||
|
||||
/// Provider capabilities - maps domain to supported content types
|
||||
/// This determines which tabs/categories show which providers
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services (support artists, albums, tracks, playlists)
|
||||
// Spotify also has audiobooks and podcasts
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Self-hosted media servers (can have music, audiobooks, and podcasts)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Audiobook and podcast providers
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'},
|
||||
// Radio providers (tunein also has podcasts)
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
'radiobrowser': {'radio'},
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
|
||||
/// Known music provider domains that provide library content
|
||||
static const Set<String> musicProviderDomains = {
|
||||
// Streaming services
|
||||
'spotify',
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'deezer',
|
||||
'ytmusic',
|
||||
'soundcloud',
|
||||
'apple_music',
|
||||
'amazon_music',
|
||||
// Self-hosted / local
|
||||
'plex',
|
||||
'subsonic',
|
||||
'opensubsonic',
|
||||
'jellyfin',
|
||||
'emby',
|
||||
'navidrome',
|
||||
'audiobookshelf',
|
||||
'filesystem',
|
||||
'itunes_podcasts',
|
||||
// Other
|
||||
'tunein',
|
||||
'radiobrowser',
|
||||
'snapcast',
|
||||
'fully_kiosk',
|
||||
};
|
||||
|
||||
/// Whether this is a music provider (vs. player provider, metadata provider, etc.)
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
|
||||
/// Get supported content types for this provider
|
||||
Set<String> get supportedContentTypes =>
|
||||
providerCapabilities[domain] ?? <String>{};
|
||||
|
||||
/// Check if this provider supports a specific content type/category
|
||||
bool supportsContentType(String category) =>
|
||||
supportedContentTypes.contains(category);
|
||||
|
||||
factory ProviderInstance.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderInstance(
|
||||
instanceId: json['instance_id'] as String? ?? '',
|
||||
domain: json['domain'] as String? ?? json['type'] as String? ?? '',
|
||||
name: json['name'] as String? ?? json['instance_id'] as String? ?? 'Unknown',
|
||||
available: json['available'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'instance_id': instanceId,
|
||||
'domain': domain,
|
||||
'name': name,
|
||||
'available': available,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderInstance($instanceId: $name)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProviderInstance &&
|
||||
runtimeType == other.runtimeType &&
|
||||
instanceId == other.instanceId;
|
||||
|
||||
@override
|
||||
int get hashCode => instanceId.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Represents a provider manifest from Music Assistant.
|
||||
///
|
||||
/// Contains metadata about a provider type including its icons.
|
||||
/// This is separate from ProviderInstance which represents a configured
|
||||
/// instance of a provider.
|
||||
class ProviderManifest {
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this provider type
|
||||
final String name;
|
||||
|
||||
/// Description of the provider
|
||||
final String? description;
|
||||
|
||||
/// SVG icon as XML string
|
||||
final String? iconSvg;
|
||||
|
||||
/// SVG icon for dark theme as XML string
|
||||
final String? iconSvgDark;
|
||||
|
||||
/// Monochrome SVG icon as XML string
|
||||
final String? iconSvgMonochrome;
|
||||
|
||||
/// Material Design icon name (fallback)
|
||||
final String? icon;
|
||||
|
||||
ProviderManifest({
|
||||
required this.domain,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.iconSvg,
|
||||
this.iconSvgDark,
|
||||
this.iconSvgMonochrome,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
/// Whether this manifest has any SVG icon available
|
||||
bool get hasSvgIcon =>
|
||||
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
|
||||
|
||||
/// Get the best available SVG icon for the given brightness
|
||||
/// Prefers dark icon for dark themes, falls back to regular or monochrome
|
||||
String? getSvgIcon({bool isDark = false}) {
|
||||
if (isDark && iconSvgDark != null) {
|
||||
return iconSvgDark;
|
||||
}
|
||||
return iconSvg ?? iconSvgMonochrome;
|
||||
}
|
||||
|
||||
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderManifest(
|
||||
domain: json['domain'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
iconSvg: json['icon_svg'] as String?,
|
||||
iconSvgDark: json['icon_svg_dark'] as String?,
|
||||
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
|
||||
icon: json['icon'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderManifest($domain: $name)';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
|
||||
/// Callback types for connection events
|
||||
typedef OnConnected = Future<void> Function();
|
||||
typedef OnAuthenticated = Future<void> Function();
|
||||
typedef OnDisconnected = void Function();
|
||||
|
||||
/// Provider for connection state management.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to handle:
|
||||
/// - WebSocket connection lifecycle
|
||||
/// - Authentication flow (MA token, credentials)
|
||||
/// - Reconnection logic
|
||||
/// - Connection state broadcasting
|
||||
///
|
||||
/// Note: This is a partial extraction. Full integration requires updating
|
||||
/// all consumers to use this provider directly.
|
||||
class ConnectionProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
final AuthManager _authManager = AuthManager();
|
||||
|
||||
MusicAssistantAPI? _api;
|
||||
String? _serverUrl;
|
||||
String? _error;
|
||||
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
||||
|
||||
StreamSubscription? _connectionStateSubscription;
|
||||
|
||||
// Callbacks for connection events - set by the facade provider
|
||||
OnConnected? onConnected;
|
||||
OnAuthenticated? onAuthenticated;
|
||||
OnDisconnected? onDisconnected;
|
||||
|
||||
// User settings captured during authentication
|
||||
List<String> _providerFilter = [];
|
||||
List<String> _playerFilter = [];
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS
|
||||
// ============================================================================
|
||||
|
||||
MusicAssistantAPI? get api => _api;
|
||||
AuthManager get authManager => _authManager;
|
||||
String? get serverUrl => _serverUrl;
|
||||
String? get error => _error;
|
||||
MAConnectionState get connectionState => _connectionState;
|
||||
|
||||
bool get isConnected =>
|
||||
_connectionState == MAConnectionState.connected ||
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
|
||||
// ============================================================================
|
||||
// CONNECTION
|
||||
// ============================================================================
|
||||
|
||||
/// Connect to the Music Assistant server
|
||||
Future<void> connect(String serverUrl) async {
|
||||
try {
|
||||
_error = null;
|
||||
_serverUrl = serverUrl;
|
||||
await SettingsService.setServerUrl(serverUrl);
|
||||
|
||||
// Dispose the old API to stop any pending reconnects
|
||||
_api?.dispose();
|
||||
|
||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||
|
||||
_connectionStateSubscription?.cancel();
|
||||
_connectionStateSubscription = _api!.connectionState.listen(
|
||||
(state) async {
|
||||
_connectionState = state;
|
||||
notifyListeners();
|
||||
|
||||
if (state == MAConnectionState.connected) {
|
||||
_logger.log('🔗 WebSocket connected to MA server');
|
||||
|
||||
if (_api!.authRequired && !_api!.isAuthenticated) {
|
||||
_logger.log('🔐 MA auth required, attempting authentication...');
|
||||
final authenticated = await _handleAuthentication();
|
||||
if (!authenticated) {
|
||||
_error = 'Authentication required. Please log in again.';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No auth required, trigger callback
|
||||
await onConnected?.call();
|
||||
} else if (state == MAConnectionState.authenticated) {
|
||||
_logger.log('✅ MA authentication successful');
|
||||
await onAuthenticated?.call();
|
||||
} else if (state == MAConnectionState.disconnected) {
|
||||
_logger.log('📡 Disconnected - keeping cached data for instant resume');
|
||||
onDisconnected?.call();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.log('Connection state stream error: $error');
|
||||
_connectionState = MAConnectionState.error;
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
await _api!.connect();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
|
||||
_error = errorInfo.userMessage;
|
||||
_connectionState = MAConnectionState.error;
|
||||
_logger.log('Connection error: ${errorInfo.technicalMessage}');
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _handleAuthentication() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
final storedToken = await SettingsService.getMaAuthToken();
|
||||
if (storedToken != null) {
|
||||
_logger.log('🔐 Trying stored MA token...');
|
||||
final success = await _api!.authenticateWithToken(storedToken);
|
||||
if (success) {
|
||||
_logger.log('✅ MA authentication with stored token successful');
|
||||
await _fetchUserSettings();
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
||||
await SettingsService.clearMaAuthToken();
|
||||
}
|
||||
|
||||
final username = await SettingsService.getUsername();
|
||||
final password = await SettingsService.getPassword();
|
||||
|
||||
if (username != null &&
|
||||
password != null &&
|
||||
username.isNotEmpty &&
|
||||
password.isNotEmpty) {
|
||||
_logger.log('🔐 Trying stored credentials...');
|
||||
final accessToken = await _api!.loginWithCredentials(username, password);
|
||||
|
||||
if (accessToken != null) {
|
||||
_logger.log('✅ MA login with stored credentials successful');
|
||||
|
||||
final longLivedToken = await _api!.createLongLivedToken();
|
||||
if (longLivedToken != null) {
|
||||
await SettingsService.setMaAuthToken(longLivedToken);
|
||||
_logger.log('✅ Saved new long-lived MA token');
|
||||
} else {
|
||||
await SettingsService.setMaAuthToken(accessToken);
|
||||
}
|
||||
|
||||
await _fetchUserSettings();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_logger.log('❌ MA authentication error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchUserSettings() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final userInfo = await _api!.getCurrentUserInfo();
|
||||
if (userInfo == null) return;
|
||||
|
||||
// Set profile name
|
||||
final displayName = userInfo['display_name'] as String?;
|
||||
final username = userInfo['username'] as String?;
|
||||
final profileName =
|
||||
(displayName != null && displayName.isNotEmpty) ? displayName : username;
|
||||
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
await SettingsService.setOwnerName(profileName);
|
||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||
}
|
||||
|
||||
// Capture provider filter
|
||||
final providerFilterRaw = userInfo['provider_filter'];
|
||||
if (providerFilterRaw is List) {
|
||||
_providerFilter = providerFilterRaw.cast<String>().toList();
|
||||
if (_providerFilter.isNotEmpty) {
|
||||
_logger.log(
|
||||
'🔒 Provider filter active: ${_providerFilter.length} providers allowed');
|
||||
}
|
||||
} else {
|
||||
_providerFilter = [];
|
||||
}
|
||||
|
||||
// Capture player filter
|
||||
final playerFilterRaw = userInfo['player_filter'];
|
||||
if (playerFilterRaw is List) {
|
||||
_playerFilter = playerFilterRaw.cast<String>().toList();
|
||||
if (_playerFilter.isNotEmpty) {
|
||||
_logger.log(
|
||||
'🔒 Player filter active: ${_playerFilter.length} players allowed');
|
||||
}
|
||||
} else {
|
||||
_playerFilter = [];
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from the server
|
||||
Future<void> disconnect() async {
|
||||
_connectionStateSubscription?.cancel();
|
||||
await _api?.disconnect();
|
||||
_connectionState = MAConnectionState.disconnected;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check connection and reconnect if needed
|
||||
Future<void> checkAndReconnect() async {
|
||||
if (_serverUrl != null && !isConnected) {
|
||||
_logger.log('🔄 Attempting reconnection...');
|
||||
await connect(_serverUrl!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectionStateSubscription?.cancel();
|
||||
_api?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
|
||||
/// Callback type for when sleep timer expires and should pause playback
|
||||
typedef OnSleepTimerExpired = void Function();
|
||||
|
||||
/// Provider for sleep timer functionality.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to reduce complexity and improve testability.
|
||||
/// This provider manages:
|
||||
/// - Timed sleep (pause after X minutes)
|
||||
/// - End of track sleep (pause when current track ends)
|
||||
class SleepTimerProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
|
||||
Timer? _sleepTimer;
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||
Timer? _sleepTimerDisplayTimer;
|
||||
|
||||
/// Callback to pause playback when timer expires
|
||||
OnSleepTimerExpired? onExpired;
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS
|
||||
// ============================================================================
|
||||
|
||||
/// Whether a sleep timer is currently active
|
||||
bool get isActive => _sleepTimerMinutes != null;
|
||||
|
||||
/// Current timer setting: null = off, -1 = end of track, positive = minutes
|
||||
int? get minutes => _sleepTimerMinutes;
|
||||
|
||||
/// Time remaining until sleep timer expires (null if end-of-track mode or inactive)
|
||||
Duration? get remaining {
|
||||
if (_sleepTimerEndTime == null) return null;
|
||||
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
|
||||
return remaining.isNegative ? Duration.zero : remaining;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIMER CONTROL
|
||||
// ============================================================================
|
||||
|
||||
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
|
||||
void setTimer(int? minutes) {
|
||||
// Cancel any existing timer
|
||||
_cancelInternal();
|
||||
|
||||
_sleepTimerMinutes = minutes;
|
||||
|
||||
if (minutes == null) {
|
||||
// Timer off
|
||||
_logger.log('😴 Sleep timer: OFF');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (minutes == -1) {
|
||||
// End of track - handled in player state updates
|
||||
_sleepTimerEndTime = null; // No fixed end time
|
||||
_logger.log('😴 Sleep timer: End of track');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set timer for specified minutes
|
||||
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
|
||||
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
|
||||
|
||||
// Create the actual timer
|
||||
_sleepTimer = Timer(Duration(minutes: minutes), _onTimerExpired);
|
||||
|
||||
// Start display update timer (every second for countdown)
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cancel sleep timer
|
||||
void cancel() {
|
||||
_cancelInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
_logger.log('😴 Sleep timer: Cancelled');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _cancelInternal() {
|
||||
_sleepTimer?.cancel();
|
||||
_sleepTimer = null;
|
||||
_sleepTimerDisplayTimer?.cancel();
|
||||
_sleepTimerDisplayTimer = null;
|
||||
}
|
||||
|
||||
void _onTimerExpired() {
|
||||
_logger.log('😴 Sleep timer expired - pausing playback');
|
||||
_cancelInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
|
||||
// Trigger the callback to pause playback
|
||||
onExpired?.call();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called when track ends - checks if "end of track" sleep timer is active
|
||||
void checkEndOfTrack() {
|
||||
if (_sleepTimerMinutes == -1) {
|
||||
_logger.log('😴 End of track sleep timer triggered');
|
||||
_sleepTimerMinutes = null;
|
||||
|
||||
// Trigger the callback to pause playback
|
||||
onExpired?.call();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelInternal();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
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 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
@@ -13,7 +14,9 @@ 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';
|
||||
|
||||
class AudiobookAuthorScreen extends StatefulWidget {
|
||||
final String authorName;
|
||||
@@ -118,6 +121,149 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle favorite status for an audiobook
|
||||
Future<void> _toggleAudiobookFavorite(Audiobook book) async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !(book.favorite ?? false);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (newState) {
|
||||
// Add to favorites
|
||||
String actualProvider = book.provider;
|
||||
String actualItemId = book.itemId;
|
||||
|
||||
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
|
||||
final mapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => book.providerMappings!.firstWhere(
|
||||
(m) => m.available,
|
||||
orElse: () => book.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
success = await maProvider.addToFavorites(
|
||||
mediaType: 'audiobook',
|
||||
itemId: actualItemId,
|
||||
provider: actualProvider,
|
||||
);
|
||||
} else {
|
||||
// Remove from favorites
|
||||
int? libraryItemId;
|
||||
if (book.provider == 'library') {
|
||||
libraryItemId = int.tryParse(book.itemId);
|
||||
} else if (book.providerMappings != null) {
|
||||
final libraryMapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => book.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() {
|
||||
final index = _audiobooks.indexWhere((b) => b.itemId == book.itemId);
|
||||
if (index != -1) {
|
||||
_audiobooks[index] = Audiobook(
|
||||
itemId: book.itemId,
|
||||
provider: book.provider,
|
||||
name: book.name,
|
||||
sortName: book.sortName,
|
||||
uri: book.uri,
|
||||
providerMappings: book.providerMappings,
|
||||
favorite: newState,
|
||||
metadata: book.metadata,
|
||||
year: book.year,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling audiobook favorite: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle library status for an audiobook
|
||||
Future<void> _toggleAudiobookLibrary(Audiobook book) async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !book.inLibrary;
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (newState) {
|
||||
// Add to library
|
||||
String? actualProvider;
|
||||
String? actualItemId;
|
||||
|
||||
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
|
||||
final mapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => book.providerMappings!.first,
|
||||
);
|
||||
if (mapping.providerInstance != 'library') {
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
}
|
||||
|
||||
if (actualProvider != null && actualItemId != null) {
|
||||
success = await maProvider.addToLibrary(
|
||||
mediaType: 'audiobook',
|
||||
itemId: actualItemId,
|
||||
provider: actualProvider,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
if (book.provider == 'library') {
|
||||
libraryItemId = int.tryParse(book.itemId);
|
||||
} else if (book.providerMappings != null) {
|
||||
final libraryMapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => book.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Refresh would require reloading from parent - for now just show success
|
||||
_logger.log('Audiobook library status toggled');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling audiobook library: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||
@@ -152,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,
|
||||
@@ -222,83 +370,112 @@ 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Audiobooks Section Header with controls
|
||||
// Audiobooks Section Header
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.audiobooks,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Sort toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
|
||||
onPressed: _toggleSortOrder,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// View mode toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_viewMode == 'list'
|
||||
? Icons.view_list
|
||||
: _viewMode == 'grid3'
|
||||
? Icons.grid_view
|
||||
: Icons.grid_on,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _viewMode == 'grid2'
|
||||
? S.of(context)!.threeColumnGrid
|
||||
: _viewMode == 'grid3'
|
||||
? S.of(context)!.listView
|
||||
: S.of(context)!.twoColumnGrid,
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0),
|
||||
child: Text(
|
||||
S.of(context)!.audiobooks,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -346,10 +523,31 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final heroSuffix = 'author${_heroTagSuffix}';
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Get adaptive scheme for context menu
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final adaptiveScheme = themeProvider.adaptiveTheme
|
||||
? (isDark ? _darkColorScheme : _lightColorScheme)
|
||||
: null;
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: book,
|
||||
isFavorite: book.favorite ?? false,
|
||||
isInLibrary: book.inLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
onToggleFavorite: () => _toggleAudiobookFavorite(book),
|
||||
onToggleLibrary: () => _toggleAudiobookLibrary(book),
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
@@ -407,6 +605,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -435,7 +638,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -447,9 +651,30 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final heroSuffix = 'author${_heroTagSuffix}';
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
leading: Hero(
|
||||
// Get adaptive scheme for context menu
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final adaptiveScheme = themeProvider.adaptiveTheme
|
||||
? (isDark ? _darkColorScheme : _lightColorScheme)
|
||||
: null;
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: book,
|
||||
isFavorite: book.favorite ?? false,
|
||||
isInLibrary: book.inLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
onToggleFavorite: () => _toggleAudiobookFavorite(book),
|
||||
onToggleLibrary: () => _toggleAudiobookLibrary(book),
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
leading: Hero(
|
||||
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -530,7 +755,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -35,6 +38,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
final _logger = DebugLogger();
|
||||
bool _isDescriptionExpanded = false;
|
||||
bool _isFavorite = false;
|
||||
bool _isInLibrary = false;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
int? _expandedChapterIndex;
|
||||
@@ -46,76 +50,103 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
// Use full audiobook if loaded, otherwise use widget audiobook
|
||||
Audiobook get _audiobook => _fullAudiobook ?? widget.audiobook;
|
||||
|
||||
/// Check if audiobook is in library
|
||||
bool _checkIfInLibrary(Audiobook audiobook) {
|
||||
if (audiobook.provider == 'library') return true;
|
||||
return audiobook.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.audiobook.favorite ?? false;
|
||||
_isInLibrary = _checkIfInLibrary(widget.audiobook);
|
||||
|
||||
// Mark that we're on a detail screen and extract colors immediately
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
markDetailScreenEntered(context);
|
||||
_extractColors(); // Extract colors immediately - async so won't block
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
_extractColors();
|
||||
_loadAudiobookDetails();
|
||||
}
|
||||
});
|
||||
// Load full audiobook details with chapters
|
||||
_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 {
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
);
|
||||
// Use isolate-based extraction to avoid blocking the main thread
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
|
||||
|
||||
if (colorSchemes != null && mounted) {
|
||||
setState(() {
|
||||
_lightColorScheme = colorSchemes.$1;
|
||||
_darkColorScheme = colorSchemes.$2;
|
||||
});
|
||||
|
||||
// Update ThemeProvider so nav bar uses adaptive colors
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Failed to extract colors for audiobook: $e');
|
||||
@@ -209,16 +240,93 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle library status
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
if (newState) {
|
||||
String? actualProvider;
|
||||
String? actualItemId;
|
||||
|
||||
if (widget.audiobook.providerMappings != null && widget.audiobook.providerMappings!.isNotEmpty) {
|
||||
final nonLibraryMapping = widget.audiobook.providerMappings!.where(
|
||||
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
||||
).firstOrNull;
|
||||
|
||||
if (nonLibraryMapping != null) {
|
||||
actualProvider = nonLibraryMapping.providerDomain;
|
||||
actualItemId = nonLibraryMapping.itemId;
|
||||
}
|
||||
}
|
||||
|
||||
if (actualProvider == null || actualItemId == null) {
|
||||
if (widget.audiobook.provider != 'library') {
|
||||
actualProvider = widget.audiobook.provider;
|
||||
actualItemId = widget.audiobook.itemId;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'audiobook',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
} else {
|
||||
int? libraryItemId;
|
||||
if (widget.audiobook.provider == 'library') {
|
||||
libraryItemId = int.tryParse(widget.audiobook.itemId);
|
||||
} else if (widget.audiobook.providerMappings != null) {
|
||||
final libraryMapping = widget.audiobook.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => widget.audiobook.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) return;
|
||||
|
||||
setState(() => _isInLibrary = newState);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
if (mounted) setState(() => _isInLibrary = !newState);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling audiobook library: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForAudiobook,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.setCurrentAudiobook(_audiobook);
|
||||
@@ -227,9 +335,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
widget.audiobook,
|
||||
);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markAsFinished() async {
|
||||
@@ -391,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;
|
||||
|
||||
@@ -434,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,
|
||||
@@ -505,41 +613,55 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.audiobook.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.audiobook.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -549,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: [
|
||||
@@ -645,7 +767,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
children: [
|
||||
// Play/Resume Button
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
@@ -679,26 +800,33 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.replay),
|
||||
child: const Icon(Icons.replay, size: 25),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Play On Button
|
||||
// Library Button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showPlayOnMenu(context),
|
||||
onPressed: _toggleLibrary,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.speaker_group_outlined),
|
||||
child: Icon(
|
||||
Symbols.book_2,
|
||||
size: 25,
|
||||
fill: _isInLibrary ? 1 : 0,
|
||||
color: _isInLibrary
|
||||
? colorScheme.primary
|
||||
: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -717,10 +845,50 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
Icons.favorite,
|
||||
size: 25,
|
||||
color: _isFavorite
|
||||
? colorScheme.error
|
||||
: colorScheme.onSurfaceVariant,
|
||||
: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Three-dot Menu Button
|
||||
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: widget.audiobook,
|
||||
isFavorite: _isFavorite,
|
||||
isInLibrary: _isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
showTopRow: false,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
size: 25,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -780,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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:palette_generator/palette_generator.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart' show BottomSpacing;
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
@@ -43,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,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);
|
||||
@@ -193,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +291,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
// Use read since we only need the provider for non-reactive operations (getImageUrl)
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final l10n = S.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
@@ -281,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(
|
||||
@@ -299,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -455,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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -489,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)
|
||||
@@ -512,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(
|
||||
@@ -523,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(
|
||||
@@ -546,6 +605,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
);
|
||||
}),
|
||||
);
|
||||
return _cachedCoverWidget!;
|
||||
}
|
||||
|
||||
/// Builds an empty cell with either a solid color or a nested grid
|
||||
@@ -663,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(
|
||||
@@ -690,6 +752,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
),
|
||||
// Progress indicator
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
@@ -748,45 +815,55 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: book.progress,
|
||||
backgroundColor: Colors.black54,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
size: 16,
|
||||
),
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: book.progress,
|
||||
backgroundColor: Colors.black54,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||