Compare commits
47
Commits
v3.0.3-beta
...
stash-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8686edd8d7 | ||
|
|
8e5d4ab002 | ||
|
|
40a2e2f43a | ||
|
|
e15cfb545e | ||
|
|
76d0f0b80e | ||
|
|
a3eb3db8a2 | ||
|
|
1877205034 | ||
|
|
bb46a2dee1 | ||
|
|
2c07134404 | ||
|
|
ea99b2bb11 | ||
|
|
9cd27553c1 | ||
|
|
0db9bb3df7 | ||
|
|
0a88d147ed | ||
|
|
c3f6710dd1 | ||
|
|
68e3b74d5d | ||
|
|
b3aa55a021 | ||
|
|
823b95ec1a | ||
|
|
59d48deaf7 | ||
|
|
753f5161fb | ||
|
|
e4cdb40fde | ||
|
|
ad5ecf59b0 | ||
|
|
3fe7a5800a | ||
|
|
972690b894 | ||
|
|
0e5679262c | ||
|
|
67bae29f9d | ||
|
|
4f7fbe92d9 | ||
|
|
0c2853b3df | ||
|
|
0d0ca75a6a | ||
|
|
661888ec37 | ||
|
|
c9b03bf045 | ||
|
|
f287fa2819 | ||
|
|
e50ca040d8 | ||
|
|
c7773ead0c | ||
|
|
ba2928a847 | ||
|
|
51c94e40a3 | ||
|
|
e6b2eb48b8 | ||
|
|
fb867f7410 | ||
|
|
e91bdf2353 | ||
|
|
547319231b | ||
|
|
42dc7727a0 | ||
|
|
803cd5694c | ||
|
|
64dc18c0fe | ||
|
|
47b4e41252 | ||
|
|
6920ea0bbd | ||
|
|
d1d29486d6 | ||
|
|
e84515664a | ||
|
|
57cb13b26d |
@@ -0,0 +1,388 @@
|
||||
# Android Auto Implementation Plan for Ensemble
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document describes adding Android Auto support to Ensemble. When a user connects their Android phone to a car head unit, Android Auto will browse and play music through Ensemble without touching the phone.
|
||||
|
||||
### What we're building
|
||||
|
||||
- A browse hierarchy (Favorites, Playlists, Albums, Radio) accessible from the car screen
|
||||
- Playback from Android Auto routes through the builtin (local) player, which streams through MA as normal
|
||||
- Voice search support
|
||||
|
||||
### Why builtin player only
|
||||
|
||||
Android Auto requires local audio output. The builtin player plays audio through `just_audio` on the device, which the car receives over Bluetooth. Remote MA players are irrelevant in a car context.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
### Provider reference
|
||||
|
||||
`MassivAudioHandler` has no reference to `MusicAssistantProvider` today. The handler is created before the provider (in `main()` before `runApp`). Solution: add a `setProvider()` method and call it from `_MusicAssistantAppState.initState()`.
|
||||
|
||||
```
|
||||
main.dart
|
||||
audioHandler = await AudioService.init(...) // global, created first
|
||||
runApp(MusicAssistantApp())
|
||||
_MusicAssistantAppState.initState()
|
||||
_musicProvider = MusicAssistantProvider()
|
||||
audioHandler.setProvider(_musicProvider) // NEW
|
||||
```
|
||||
|
||||
### Browse → play call flow
|
||||
|
||||
```
|
||||
Android Auto MassivAudioHandler MusicAssistantProvider
|
||||
| | |
|
||||
getChildren('root') -----> | returns category items |
|
||||
getChildren('cat|playlists')-> | SyncService.cachedPlaylists |
|
||||
getChildren('playlist|lib|5')-> | provider.getPlaylistTracksWithCache()
|
||||
| populates _trackQueueCache |
|
||||
playFromMediaId('track|lib|7|plist|lib|5') -> |
|
||||
| lookup _trackQueueCache |
|
||||
| provider.playTracks(builtinId, tracks, startIndex: i)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Media ID Scheme
|
||||
|
||||
Separator: `|` (pipe). Provider names use underscores/colons, never pipes — safe.
|
||||
|
||||
| ID | Meaning |
|
||||
|----|---------|
|
||||
| `root` | Root (browsable) |
|
||||
| `recent` | Currently playing track (playable) |
|
||||
| `cat\|favorites` | Favorites category (browsable) |
|
||||
| `cat\|playlists` | Playlists category (browsable) |
|
||||
| `cat\|albums` | Albums category (browsable) |
|
||||
| `cat\|radio` | Radio stations category (browsable) |
|
||||
| `playlist\|{provider}\|{itemId}` | A playlist (browsable) |
|
||||
| `album\|{provider}\|{itemId}` | An album (browsable) |
|
||||
| `track\|{tProvider}\|{tItemId}\|{ctxType}\|{ctxProvider}\|{ctxId}` | A track in context (playable) |
|
||||
| `radio\|{provider}\|{itemId}` | A radio station (playable) |
|
||||
|
||||
The `ctxType|ctxProvider|ctxId` suffix in track IDs encodes the parent container. Context types: `plist`, `album`, `favs`, `search`.
|
||||
|
||||
**Track queue cache key** = `{ctxType}|{ctxProvider}|{ctxId}` (e.g. `plist|spotify|abc`). Stored when building children, looked up in `playFromMediaId`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Browse Hierarchy
|
||||
|
||||
```
|
||||
root
|
||||
├── recent (playable, shown if something is/was playing)
|
||||
├── cat|favorites (browsable → favorite tracks, all playable)
|
||||
├── cat|playlists (browsable → list of playlists, each browsable)
|
||||
│ └── playlist|{provider}|{id} → tracks (playable)
|
||||
├── cat|albums (browsable → list of albums, each browsable)
|
||||
│ └── album|{provider}|{id} → tracks (playable)
|
||||
└── cat|radio (browsable → radio stations, each playable)
|
||||
```
|
||||
|
||||
Artists are **not included**. Navigating Artist → Albums → Album → Tracks is four taps — too many while driving. Can be added later.
|
||||
|
||||
---
|
||||
|
||||
## 5. Track Queue Cache
|
||||
|
||||
Tapping a track in Android Auto should play the full album/playlist from that track, not just the single track. The handler caches track lists when building children:
|
||||
|
||||
```dart
|
||||
// Key: ctxKey e.g. "plist|spotify|abc" or "album|library|42" or "favs||"
|
||||
// Value: ordered list of tracks in that container
|
||||
final Map<String, List<ma.Track>> _trackQueueCache = {};
|
||||
```
|
||||
|
||||
When `getChildren('playlist|spotify|abc')` is called:
|
||||
1. Fetch tracks via `provider.getPlaylistTracksWithCache('spotify', 'abc')`
|
||||
2. Store `_trackQueueCache['plist|spotify|abc'] = tracks`
|
||||
3. Return each track as a playable `MediaItem` with id `track|{t.provider}|{t.itemId}|plist|spotify|abc`
|
||||
|
||||
When `playFromMediaId('track|lib|7|plist|spotify|abc')` is called:
|
||||
1. Parse ctxKey = `plist|spotify|abc`
|
||||
2. Look up `_trackQueueCache['plist|spotify|abc']`
|
||||
3. Find track index where `itemId == '7'`
|
||||
4. Call `provider.playTracks(builtinId, tracks, startIndex: index)`
|
||||
|
||||
---
|
||||
|
||||
## 6. Key Code for `massiv_audio_handler.dart`
|
||||
|
||||
### New imports
|
||||
|
||||
```dart
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../settings_service.dart';
|
||||
import '../sync_service.dart';
|
||||
import '../../models/media_item.dart' as ma;
|
||||
```
|
||||
|
||||
Note: `audio_service` exports its own `MediaItem`. Use `ma.MediaItem`, `ma.Track`, `ma.Album`, `ma.Playlist` for app models. The bare `MediaItem` refers to the audio_service type throughout new methods.
|
||||
|
||||
### New fields
|
||||
|
||||
```dart
|
||||
MusicAssistantProvider? _provider;
|
||||
final Map<String, ma.Track> _trackQueueCache = {}; // ctxKey -> tracks (see section 5)
|
||||
final Map<String, BehaviorSubject<Map<String, dynamic>>> _childrenSubjects = {};
|
||||
|
||||
void setProvider(MusicAssistantProvider provider) {
|
||||
_provider = provider;
|
||||
}
|
||||
```
|
||||
|
||||
### `getChildren`
|
||||
|
||||
```dart
|
||||
@override
|
||||
Future<List<MediaItem>> getChildren(String parentMediaId,
|
||||
[Map<String, dynamic>? options]) async {
|
||||
final provider = _provider;
|
||||
if (provider == null) return [];
|
||||
|
||||
switch (parentMediaId) {
|
||||
case AudioService.browsableRootId:
|
||||
return _buildRoot(provider);
|
||||
case 'cat|favorites':
|
||||
return _buildFavorites(provider);
|
||||
case 'cat|playlists':
|
||||
return _buildPlaylistList(provider);
|
||||
case 'cat|albums':
|
||||
return _buildAlbumList(provider);
|
||||
case 'cat|radio':
|
||||
return _buildRadioList(provider);
|
||||
default:
|
||||
if (parentMediaId.startsWith('playlist|')) {
|
||||
final parts = parentMediaId.split('|');
|
||||
if (parts.length >= 3) return _buildPlaylistTracks(provider, parts[1], parts[2]);
|
||||
}
|
||||
if (parentMediaId.startsWith('album|')) {
|
||||
final parts = parentMediaId.split('|');
|
||||
if (parts.length >= 3) return _buildAlbumTracks(provider, parts[1], parts[2]);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `playFromMediaId`
|
||||
|
||||
```dart
|
||||
@override
|
||||
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) async {
|
||||
final provider = _provider;
|
||||
if (provider == null) return;
|
||||
|
||||
final playerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (playerId == null) return;
|
||||
|
||||
_logger.log('AndroidAuto: playFromMediaId $mediaId');
|
||||
|
||||
try {
|
||||
if (mediaId == 'recent') {
|
||||
await provider.playPauseSelectedPlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaId.startsWith('radio|')) {
|
||||
final parts = mediaId.split('|'); // ['radio', provider, itemId]
|
||||
if (parts.length < 3) return;
|
||||
final station = provider.radioStations.firstWhere(
|
||||
(s) => s.provider == parts[1] && s.itemId == parts[2],
|
||||
orElse: () => throw Exception('Station not found: $mediaId'),
|
||||
);
|
||||
await provider.playRadio(playerId, station);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaId.startsWith('track|')) {
|
||||
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
|
||||
final parts = mediaId.split('|');
|
||||
if (parts.length < 6) return;
|
||||
final tProvider = parts[1];
|
||||
final tItemId = parts[2];
|
||||
final ctxKey = parts.sublist(3).join('|');
|
||||
|
||||
final trackList = _trackQueueCache[ctxKey];
|
||||
if (trackList == null || trackList.isEmpty) {
|
||||
// Fallback: play single track (minimal Track object)
|
||||
await provider.playTrack(playerId, ma.Track(itemId: tItemId, provider: tProvider, name: ''));
|
||||
return;
|
||||
}
|
||||
|
||||
final index = trackList.indexWhere((t) => t.provider == tProvider && t.itemId == tItemId);
|
||||
await provider.playTracks(playerId, trackList, startIndex: index < 0 ? 0 : index);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('AndroidAuto: playFromMediaId error: $e');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `search`
|
||||
|
||||
```dart
|
||||
@override
|
||||
Future<List<MediaItem>> search(String query, [Map<String, dynamic>? extras]) async {
|
||||
final provider = _provider;
|
||||
if (provider == null || query.trim().isEmpty) return [];
|
||||
|
||||
try {
|
||||
final results = await provider.searchWithCache(query);
|
||||
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
|
||||
const ctxKey = 'search||';
|
||||
_trackQueueCache[ctxKey] = tracks;
|
||||
|
||||
return tracks.map((t) => MediaItem(
|
||||
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
|
||||
title: t.name,
|
||||
artist: t.artistsString,
|
||||
album: t.album?.name,
|
||||
duration: t.duration,
|
||||
artUri: _artUri(provider, t),
|
||||
playable: true,
|
||||
)).toList();
|
||||
} catch (e) {
|
||||
_logger.log('AndroidAuto: search error: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `subscribeToChildren`
|
||||
|
||||
```dart
|
||||
@override
|
||||
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
|
||||
_childrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
|
||||
return _childrenSubjects[parentMediaId]!.stream;
|
||||
}
|
||||
```
|
||||
|
||||
### `_artUri` helper
|
||||
|
||||
```dart
|
||||
Uri? _artUri(MusicAssistantProvider provider, ma.MediaItem item) {
|
||||
final url = provider.getImageUrl(item, size: 256);
|
||||
return url != null ? Uri.tryParse(url) : null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Files to Change
|
||||
|
||||
### `pubspec.yaml`
|
||||
Add `rxdart: ^0.28.0` as direct dependency. The `^0.28.0` constraint matches the already-resolved transitive version so no upgrade occurs.
|
||||
|
||||
### `android/app/build.gradle`
|
||||
Change:
|
||||
```groovy
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
```
|
||||
To:
|
||||
```groovy
|
||||
minSdkVersion 23
|
||||
```
|
||||
Android Auto requires API 23+. `flutter.minSdkVersion` resolves to 21 — too low.
|
||||
|
||||
### `android/app/src/main/AndroidManifest.xml`
|
||||
Add inside `<application>` tag:
|
||||
```xml
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
```
|
||||
The `MediaBrowserService` intent filter on `AudioService` is already present — no changes needed there.
|
||||
|
||||
### `android/app/src/main/res/xml/automotive_app_desc.xml` (NEW)
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
```
|
||||
|
||||
### `lib/main.dart`
|
||||
In `_MusicAssistantAppState.initState()`, after creating `_musicProvider`:
|
||||
```dart
|
||||
audioHandler.setProvider(_musicProvider);
|
||||
```
|
||||
|
||||
### `lib/services/audio/massiv_audio_handler.dart`
|
||||
Add ~200 lines. New imports, fields, `setProvider()`, `getChildren()`, `playFromMediaId()`, `search()`, `subscribeToChildren()`, and private builder helpers. All existing code is untouched.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing with DHU (No Car Required)
|
||||
|
||||
### Install DHU
|
||||
In Android Studio: SDK Manager → SDK Tools → Android Auto Desktop Head Unit Emulator.
|
||||
|
||||
Or via command line:
|
||||
```bash
|
||||
sdkmanager "extras;google;auto"
|
||||
```
|
||||
|
||||
### Enable Android Auto developer mode on phone
|
||||
1. Settings → Apps → Android Auto → tap version number 10 times
|
||||
2. In Android Auto developer settings: enable "Unknown sources"
|
||||
|
||||
### Run the test session
|
||||
```bash
|
||||
# Terminal 1: forward the port
|
||||
adb forward tcp:5277 tcp:5277
|
||||
|
||||
# Terminal 2: start the DHU
|
||||
$ANDROID_SDK_ROOT/extras/google/auto/desktop-head-unit
|
||||
```
|
||||
|
||||
In Android Auto developer settings on phone: tap "Start head unit server".
|
||||
|
||||
### Build and install
|
||||
```bash
|
||||
flutter build apk --debug
|
||||
adb install build/app/outputs/flutter-apk/app-debug.apk
|
||||
```
|
||||
|
||||
Launch Ensemble on phone first (so audio service starts). Then tap the media icon in DHU — Ensemble should appear.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| App not visible in DHU | Missing `automotive_app_desc.xml` or wrong meta-data name | Check manifest and XML |
|
||||
| App visible, browse is empty | `setProvider` not yet called | Check `initState` wiring |
|
||||
| Build fails | `minSdkVersion` too low | Check `build.gradle` |
|
||||
| Track plays but wrong song | `_trackQueueCache` miss | Ensure `getChildren` ran before `playFromMediaId` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Order
|
||||
|
||||
Do these steps in order to test incrementally:
|
||||
|
||||
1. Android config files (`build.gradle`, `AndroidManifest.xml`, `automotive_app_desc.xml`) — verify app appears in DHU before writing Dart
|
||||
2. `pubspec.yaml` + `flutter pub get`
|
||||
3. `massiv_audio_handler.dart`: add `setProvider`, fields, `subscribeToChildren` — minimum to not crash
|
||||
4. `main.dart`: wire `setProvider`
|
||||
5. `massiv_audio_handler.dart`: `getChildren` with hardcoded root categories — verify tree appears in DHU
|
||||
6. `massiv_audio_handler.dart`: add all `_build*` helpers one by one, test each
|
||||
7. `massiv_audio_handler.dart`: add `playFromMediaId` — test track playback
|
||||
8. `massiv_audio_handler.dart`: add `search` — test voice search
|
||||
|
||||
---
|
||||
|
||||
## 10. Not In Scope
|
||||
|
||||
- **Artists browse** — too many taps for driving; albums alone suffice
|
||||
- **Audiobooks/podcasts** — different UX needs; add later
|
||||
- **Queue management from Android Auto** — MA queue model is complex
|
||||
- **Push updates** — `notifyChildrenChanged` stubbed but not wired to sync events
|
||||
- **Android Automotive OS** — different platform, requires a separate flavor
|
||||
@@ -1,7 +1,8 @@
|
||||
<div align="center">
|
||||
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
|
||||
|
||||
[](https://github.com/CollotsSpot/Ensemble/releases)
|
||||
[](https://github.com/CollotsSpot/Ensemble/releases)
|
||||
[](https://github.com/CollotsSpot/Ensemble/releases/latest)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/sponsors/CollotsSpot)
|
||||
[](https://ko-fi.com/collotsspot)
|
||||
@@ -85,6 +86,13 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
- **Skip Controls** - Skip forward/backward during playback
|
||||
- **High-Resolution Artwork** - Fetched via iTunes for best quality
|
||||
|
||||
### Android Auto
|
||||
- **Media Browsing** - Browse your library from your car's display with categories for Home, Music, Audiobooks, Podcasts, and Radio
|
||||
- **Voice Search** - Find and play tracks using voice commands
|
||||
- **Full Playback Controls** - Play, pause, and skip from your car's head unit
|
||||
- **Album Art & Metadata** - Track info and artwork displayed on your car's screen
|
||||
- **Smart Queueing** - Tap any track in an album or playlist to play from that point with the full queue
|
||||
|
||||
### Smart Features
|
||||
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
||||
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
||||
@@ -140,6 +148,17 @@ If you run Music Assistant as a Home Assistant add-on:
|
||||
- Use the IP address of your Home Assistant server
|
||||
- Do **not** use your Home Assistant URL or ingress URL
|
||||
|
||||
### Android Auto
|
||||
|
||||
Since Ensemble is not installed from the Play Store, you need to enable unknown sources in Android Auto:
|
||||
|
||||
1. Open **Android Auto** settings on your phone
|
||||
2. Tap the **Version** number at the bottom 10 times to enable developer mode
|
||||
3. Tap the three-dot menu and open **Developer settings**
|
||||
4. Enable **Unknown sources**
|
||||
5. Connect your phone to your car via USB or wirelessly
|
||||
6. Ensemble will appear in Android Auto's app list
|
||||
|
||||
### Remote Access
|
||||
|
||||
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
|
||||
|
||||
@@ -54,6 +54,16 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name=".ArtworkContentProvider"
|
||||
android:authorities="com.collotsspot.ensemble.artwork"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.collotsspot.ensemble
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Base64
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* ContentProvider that serves artwork images to Android Auto.
|
||||
*
|
||||
* Android Auto only supports content:// and android.resource:// URIs for artwork.
|
||||
* This provider accepts a base64url-encoded HTTP URL in the path, downloads the
|
||||
* image (with disk caching), and returns a ParcelFileDescriptor.
|
||||
*
|
||||
* URI format: content://com.collotsspot.ensemble.artwork/{base64url_encoded_http_url}
|
||||
*/
|
||||
class ArtworkContentProvider : ContentProvider() {
|
||||
|
||||
companion object {
|
||||
private const val CONNECT_TIMEOUT_MS = 5000
|
||||
private const val READ_TIMEOUT_MS = 10000
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||
val encoded = uri.lastPathSegment ?: return null
|
||||
val httpUrl: String
|
||||
try {
|
||||
httpUrl = String(Base64.decode(encoded, Base64.URL_SAFE or Base64.NO_WRAP))
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val cacheDir = File(context?.cacheDir, "artwork")
|
||||
if (!cacheDir.exists()) cacheDir.mkdirs()
|
||||
|
||||
val hash = md5(httpUrl)
|
||||
val cacheFile = File(cacheDir, hash)
|
||||
|
||||
if (!cacheFile.exists()) {
|
||||
try {
|
||||
val connection = URL(httpUrl).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
connection.readTimeout = READ_TIMEOUT_MS
|
||||
connection.instanceFollowRedirects = true
|
||||
try {
|
||||
connection.inputStream.use { input ->
|
||||
cacheFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
cacheFile.delete()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
|
||||
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
|
||||
selectionArgs: Array<String>?, sortOrder: String?): Cursor? = null
|
||||
override fun getType(uri: Uri): String = "image/*"
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?,
|
||||
selectionArgs: Array<String>?): Int = 0
|
||||
|
||||
private fun md5(input: String): String {
|
||||
val digest = MessageDigest.getInstance("MD5")
|
||||
val bytes = digest.digest(input.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
package com.collotsspot.ensemble
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.database.ContentObserver
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.app.UiModeManager
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.KeyEvent
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
@@ -17,7 +24,10 @@ import io.flutter.plugin.common.MethodChannel
|
||||
class MainActivity: AudioServiceActivity() {
|
||||
private val TAG = "EnsembleVolume"
|
||||
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
|
||||
private val AA_CHANNEL = "com.collotsspot.ensemble/android_auto"
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var aaChannel: MethodChannel? = null
|
||||
private var carModeReceiver: BroadcastReceiver? = null
|
||||
private var isListening = false
|
||||
|
||||
// Volume observer for lockscreen volume changes
|
||||
@@ -30,6 +40,10 @@ class MainActivity: AudioServiceActivity() {
|
||||
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
|
||||
private var ignoringVolumeChange = false
|
||||
|
||||
// Android Auto projection tracking via display listener
|
||||
private var isAATracked = false
|
||||
private var displayListener: DisplayManager.DisplayListener? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
|
||||
@@ -74,6 +88,65 @@ class MainActivity: AudioServiceActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Android Auto / car mode detection
|
||||
aaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AA_CHANNEL)
|
||||
|
||||
// Receive AA connection signal from Dart side
|
||||
aaChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"notifyAAConnected" -> {
|
||||
Log.d(TAG, "AA tracked via Dart notifyAAConnected")
|
||||
isAATracked = true
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
carModeReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
UiModeManager.ACTION_ENTER_CAR_MODE -> {
|
||||
Log.d(TAG, "Car mode ENTERED")
|
||||
isAATracked = true
|
||||
aaChannel?.invokeMethod("onAndroidAutoConnected", null)
|
||||
}
|
||||
UiModeManager.ACTION_EXIT_CAR_MODE -> {
|
||||
Log.d(TAG, "Car mode EXITED")
|
||||
aaChannel?.invokeMethod("onAndroidAutoDisconnected", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val carModeFilter = IntentFilter().apply {
|
||||
addAction(UiModeManager.ACTION_ENTER_CAR_MODE)
|
||||
addAction(UiModeManager.ACTION_EXIT_CAR_MODE)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
applicationContext.registerReceiver(carModeReceiver, carModeFilter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
applicationContext.registerReceiver(carModeReceiver, carModeFilter)
|
||||
}
|
||||
|
||||
// Detect AA disconnect via display removal (projection mode)
|
||||
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) {}
|
||||
override fun onDisplayChanged(displayId: Int) {}
|
||||
override fun onDisplayRemoved(displayId: Int) {
|
||||
if (!isAATracked) return
|
||||
val hasExtraDisplay = dm.displays.any {
|
||||
it.displayId != Display.DEFAULT_DISPLAY
|
||||
}
|
||||
if (!hasExtraDisplay) {
|
||||
Log.d(TAG, "AA projection ended (all extra displays removed)")
|
||||
isAATracked = false
|
||||
aaChannel?.invokeMethod("onAndroidAutoDisconnected", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
dm.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
|
||||
}
|
||||
|
||||
/// Start observing system STREAM_MUSIC volume changes.
|
||||
@@ -224,6 +297,13 @@ class MainActivity: AudioServiceActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
stopVolumeObserver()
|
||||
carModeReceiver?.let {
|
||||
try { applicationContext.unregisterReceiver(it) } catch (_: Exception) {}
|
||||
}
|
||||
displayListener?.let {
|
||||
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
dm.unregisterDisplayListener(it)
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,16.5c-2.49,0 -4.5,-2.01 -4.5,-4.5S9.51,7.5 12,7.5s4.5,2.01 4.5,4.5 -2.01,4.5 -4.5,4.5zM12,11c-0.55,0 -1,0.45 -1,1s0.45,1 1,1 1,-0.45 1,-1 -0.45,-1 -1,-1z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M16.5,3c-1.74,0 -3.41,0.81 -4.5,2.09C10.91,3.81 9.24,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.54L12,21.35l1.45,-1.32C18.6,15.36 22,12.28 22,8.5 22,5.42 19.58,3 16.5,3zM12.1,18.55l-0.1,0.1 -0.1,-0.1C7.14,14.24 4,11.39 4,8.5 4,6.5 5.5,5 7.5,5c1.54,0 3.04,0.99 3.57,2.36h1.87C13.46,5.99 14.96,5 16.5,5c2,0 3.5,1.5 3.5,3.5 0,2.89 -3.14,5.74 -7.9,10.05z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55C7.79,13 6,14.79 6,17s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M15,6H3v2h12V6zM15,10H3v2h12V10zM3,16h8v-2H3V16zM17,6v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3V8h3V6H17z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M3.24,6.15C2.51,6.43 2,7.17 2,8v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2H8.3l8.26,-3.34 -0.37,-0.92L3.24,6.15zM7,20c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM20,12H12V9h8v3z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M4,22h16v2H4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M13,15V9h-1l-2,1v1h1.5v4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M4,22h16v2H4z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<!-- Shuffle arrows -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z"/>
|
||||
<!-- Sparkle -->
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M19,1l-1.26,2.74L15,5l2.74,1.26L19,9l1.26,-2.74L23,5l-2.74,-1.26z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -190,12 +190,37 @@ class SearchHistory extends Table {
|
||||
DateTimeColumn get searchedAt => dateTime()();
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
|
||||
/// Cached detail tracks for album/playlist detail screens
|
||||
/// Persists across app restarts so detail screens load instantly
|
||||
class DetailTrackCache extends Table {
|
||||
/// Composite key: "{parentType}_{parentKey}"
|
||||
TextColumn get cacheKey => text()();
|
||||
|
||||
/// Type: 'album' or 'playlist'
|
||||
TextColumn get parentType => text()();
|
||||
|
||||
/// The parent item's cache key (e.g., "provider_itemId")
|
||||
TextColumn get parentKey => text()();
|
||||
|
||||
/// Serialized list of tracks as JSON array
|
||||
TextColumn get tracksJson => text()();
|
||||
|
||||
/// When this was fetched from API
|
||||
DateTimeColumn get lastFetched => dateTime()();
|
||||
|
||||
/// When this was last accessed (for LRU eviction)
|
||||
DateTimeColumn get lastAccessed => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {cacheKey};
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory, DetailTrackCache])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 6;
|
||||
int get schemaVersion => 7;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -234,6 +259,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 6) {
|
||||
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
// Migration from v6 to v7: Add detail track cache table
|
||||
if (from < 7) {
|
||||
await m.createTable(detailTrackCache);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -696,6 +725,66 @@ class AppDatabase extends _$AppDatabase {
|
||||
await delete(searchHistory).go();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Detail Track Cache Operations
|
||||
// ============================================
|
||||
|
||||
/// Save detail tracks for an album or playlist
|
||||
Future<void> saveDetailTracks(String parentType, String parentKey, String tracksJson) async {
|
||||
final cacheKey = '${parentType}_$parentKey';
|
||||
final now = DateTime.now();
|
||||
await into(detailTrackCache).insertOnConflictUpdate(
|
||||
DetailTrackCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
parentType: parentType,
|
||||
parentKey: parentKey,
|
||||
tracksJson: tracksJson,
|
||||
lastFetched: now,
|
||||
lastAccessed: now,
|
||||
),
|
||||
);
|
||||
// Evict old entries if cache grows too large
|
||||
await _evictDetailTrackCache();
|
||||
}
|
||||
|
||||
/// Get cached detail tracks for an album or playlist
|
||||
/// Returns null if not cached, updates lastAccessed on hit
|
||||
Future<DetailTrackCacheData?> getDetailTracks(String parentType, String parentKey) async {
|
||||
final cacheKey = '${parentType}_$parentKey';
|
||||
final result = await (select(detailTrackCache)
|
||||
..where((d) => d.cacheKey.equals(cacheKey)))
|
||||
.getSingleOrNull();
|
||||
if (result != null) {
|
||||
// Update last accessed time
|
||||
await (update(detailTrackCache)..where((d) => d.cacheKey.equals(cacheKey)))
|
||||
.write(DetailTrackCacheCompanion(lastAccessed: Value(DateTime.now())));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Evict oldest detail track cache entries when over limit
|
||||
Future<void> _evictDetailTrackCache() async {
|
||||
const maxEntries = 100;
|
||||
final count = await (selectOnly(detailTrackCache)..addColumns([countAll()]))
|
||||
.map((row) => row.read(countAll())!)
|
||||
.getSingle();
|
||||
if (count <= maxEntries) return;
|
||||
|
||||
// Delete oldest by lastAccessed
|
||||
final oldest = await (select(detailTrackCache)
|
||||
..orderBy([(d) => OrderingTerm.asc(d.lastAccessed)])
|
||||
..limit(count - maxEntries))
|
||||
.get();
|
||||
for (final entry in oldest) {
|
||||
await (delete(detailTrackCache)..where((d) => d.cacheKey.equals(entry.cacheKey))).go();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all detail track cache
|
||||
Future<void> clearDetailTrackCache() async {
|
||||
await delete(detailTrackCache).go();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Batch Cache Operations
|
||||
// ============================================
|
||||
|
||||
@@ -2916,6 +2916,381 @@ class SearchHistoryCompanion extends UpdateCompanion<SearchHistoryData> {
|
||||
}
|
||||
}
|
||||
|
||||
class $DetailTrackCacheTable extends DetailTrackCache
|
||||
with TableInfo<$DetailTrackCacheTable, DetailTrackCacheData> {
|
||||
@override
|
||||
final GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$DetailTrackCacheTable(this.attachedDatabase, [this._alias]);
|
||||
static const VerificationMeta _cacheKeyMeta =
|
||||
const VerificationMeta('cacheKey');
|
||||
@override
|
||||
late final GeneratedColumn<String> cacheKey = GeneratedColumn<String>(
|
||||
'cache_key', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _parentTypeMeta =
|
||||
const VerificationMeta('parentType');
|
||||
@override
|
||||
late final GeneratedColumn<String> parentType = GeneratedColumn<String>(
|
||||
'parent_type', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _parentKeyMeta =
|
||||
const VerificationMeta('parentKey');
|
||||
@override
|
||||
late final GeneratedColumn<String> parentKey = GeneratedColumn<String>(
|
||||
'parent_key', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _tracksJsonMeta =
|
||||
const VerificationMeta('tracksJson');
|
||||
@override
|
||||
late final GeneratedColumn<String> tracksJson = GeneratedColumn<String>(
|
||||
'tracks_json', aliasedName, false,
|
||||
type: DriftSqlType.string, requiredDuringInsert: true);
|
||||
static const VerificationMeta _lastFetchedMeta =
|
||||
const VerificationMeta('lastFetched');
|
||||
@override
|
||||
late final GeneratedColumn<DateTime> lastFetched = GeneratedColumn<DateTime>(
|
||||
'last_fetched', aliasedName, false,
|
||||
type: DriftSqlType.dateTime, requiredDuringInsert: true);
|
||||
static const VerificationMeta _lastAccessedMeta =
|
||||
const VerificationMeta('lastAccessed');
|
||||
@override
|
||||
late final GeneratedColumn<DateTime> lastAccessed = GeneratedColumn<DateTime>(
|
||||
'last_accessed', aliasedName, false,
|
||||
type: DriftSqlType.dateTime, requiredDuringInsert: true);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns =>
|
||||
[cacheKey, parentType, parentKey, tracksJson, lastFetched, lastAccessed];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'detail_track_cache';
|
||||
@override
|
||||
VerificationContext validateIntegrity(
|
||||
Insertable<DetailTrackCacheData> instance,
|
||||
{bool isInserting = false}) {
|
||||
final context = VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('cache_key')) {
|
||||
context.handle(_cacheKeyMeta,
|
||||
cacheKey.isAcceptableOrUnknown(data['cache_key']!, _cacheKeyMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_cacheKeyMeta);
|
||||
}
|
||||
if (data.containsKey('parent_type')) {
|
||||
context.handle(
|
||||
_parentTypeMeta,
|
||||
parentType.isAcceptableOrUnknown(
|
||||
data['parent_type']!, _parentTypeMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_parentTypeMeta);
|
||||
}
|
||||
if (data.containsKey('parent_key')) {
|
||||
context.handle(_parentKeyMeta,
|
||||
parentKey.isAcceptableOrUnknown(data['parent_key']!, _parentKeyMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_parentKeyMeta);
|
||||
}
|
||||
if (data.containsKey('tracks_json')) {
|
||||
context.handle(
|
||||
_tracksJsonMeta,
|
||||
tracksJson.isAcceptableOrUnknown(
|
||||
data['tracks_json']!, _tracksJsonMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_tracksJsonMeta);
|
||||
}
|
||||
if (data.containsKey('last_fetched')) {
|
||||
context.handle(
|
||||
_lastFetchedMeta,
|
||||
lastFetched.isAcceptableOrUnknown(
|
||||
data['last_fetched']!, _lastFetchedMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_lastFetchedMeta);
|
||||
}
|
||||
if (data.containsKey('last_accessed')) {
|
||||
context.handle(
|
||||
_lastAccessedMeta,
|
||||
lastAccessed.isAcceptableOrUnknown(
|
||||
data['last_accessed']!, _lastAccessedMeta));
|
||||
} else if (isInserting) {
|
||||
context.missing(_lastAccessedMeta);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<GeneratedColumn> get $primaryKey => {cacheKey};
|
||||
@override
|
||||
DetailTrackCacheData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return DetailTrackCacheData(
|
||||
cacheKey: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}cache_key'])!,
|
||||
parentType: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}parent_type'])!,
|
||||
parentKey: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}parent_key'])!,
|
||||
tracksJson: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.string, data['${effectivePrefix}tracks_json'])!,
|
||||
lastFetched: attachedDatabase.typeMapping
|
||||
.read(DriftSqlType.dateTime, data['${effectivePrefix}last_fetched'])!,
|
||||
lastAccessed: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.dateTime, data['${effectivePrefix}last_accessed'])!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$DetailTrackCacheTable createAlias(String alias) {
|
||||
return $DetailTrackCacheTable(attachedDatabase, alias);
|
||||
}
|
||||
}
|
||||
|
||||
class DetailTrackCacheData extends DataClass
|
||||
implements Insertable<DetailTrackCacheData> {
|
||||
/// Composite key: "{parentType}_{parentKey}"
|
||||
final String cacheKey;
|
||||
|
||||
/// Type: 'album' or 'playlist'
|
||||
final String parentType;
|
||||
|
||||
/// The parent item's cache key (e.g., "provider_itemId")
|
||||
final String parentKey;
|
||||
|
||||
/// Serialized list of tracks as JSON array
|
||||
final String tracksJson;
|
||||
|
||||
/// When this was fetched from API
|
||||
final DateTime lastFetched;
|
||||
|
||||
/// When this was last accessed (for LRU eviction)
|
||||
final DateTime lastAccessed;
|
||||
const DetailTrackCacheData(
|
||||
{required this.cacheKey,
|
||||
required this.parentType,
|
||||
required this.parentKey,
|
||||
required this.tracksJson,
|
||||
required this.lastFetched,
|
||||
required this.lastAccessed});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['cache_key'] = Variable<String>(cacheKey);
|
||||
map['parent_type'] = Variable<String>(parentType);
|
||||
map['parent_key'] = Variable<String>(parentKey);
|
||||
map['tracks_json'] = Variable<String>(tracksJson);
|
||||
map['last_fetched'] = Variable<DateTime>(lastFetched);
|
||||
map['last_accessed'] = Variable<DateTime>(lastAccessed);
|
||||
return map;
|
||||
}
|
||||
|
||||
DetailTrackCacheCompanion toCompanion(bool nullToAbsent) {
|
||||
return DetailTrackCacheCompanion(
|
||||
cacheKey: Value(cacheKey),
|
||||
parentType: Value(parentType),
|
||||
parentKey: Value(parentKey),
|
||||
tracksJson: Value(tracksJson),
|
||||
lastFetched: Value(lastFetched),
|
||||
lastAccessed: Value(lastAccessed),
|
||||
);
|
||||
}
|
||||
|
||||
factory DetailTrackCacheData.fromJson(Map<String, dynamic> json,
|
||||
{ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return DetailTrackCacheData(
|
||||
cacheKey: serializer.fromJson<String>(json['cacheKey']),
|
||||
parentType: serializer.fromJson<String>(json['parentType']),
|
||||
parentKey: serializer.fromJson<String>(json['parentKey']),
|
||||
tracksJson: serializer.fromJson<String>(json['tracksJson']),
|
||||
lastFetched: serializer.fromJson<DateTime>(json['lastFetched']),
|
||||
lastAccessed: serializer.fromJson<DateTime>(json['lastAccessed']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'cacheKey': serializer.toJson<String>(cacheKey),
|
||||
'parentType': serializer.toJson<String>(parentType),
|
||||
'parentKey': serializer.toJson<String>(parentKey),
|
||||
'tracksJson': serializer.toJson<String>(tracksJson),
|
||||
'lastFetched': serializer.toJson<DateTime>(lastFetched),
|
||||
'lastAccessed': serializer.toJson<DateTime>(lastAccessed),
|
||||
};
|
||||
}
|
||||
|
||||
DetailTrackCacheData copyWith(
|
||||
{String? cacheKey,
|
||||
String? parentType,
|
||||
String? parentKey,
|
||||
String? tracksJson,
|
||||
DateTime? lastFetched,
|
||||
DateTime? lastAccessed}) =>
|
||||
DetailTrackCacheData(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
parentType: parentType ?? this.parentType,
|
||||
parentKey: parentKey ?? this.parentKey,
|
||||
tracksJson: tracksJson ?? this.tracksJson,
|
||||
lastFetched: lastFetched ?? this.lastFetched,
|
||||
lastAccessed: lastAccessed ?? this.lastAccessed,
|
||||
);
|
||||
DetailTrackCacheData copyWithCompanion(DetailTrackCacheCompanion data) {
|
||||
return DetailTrackCacheData(
|
||||
cacheKey: data.cacheKey.present ? data.cacheKey.value : this.cacheKey,
|
||||
parentType:
|
||||
data.parentType.present ? data.parentType.value : this.parentType,
|
||||
parentKey: data.parentKey.present ? data.parentKey.value : this.parentKey,
|
||||
tracksJson:
|
||||
data.tracksJson.present ? data.tracksJson.value : this.tracksJson,
|
||||
lastFetched:
|
||||
data.lastFetched.present ? data.lastFetched.value : this.lastFetched,
|
||||
lastAccessed: data.lastAccessed.present
|
||||
? data.lastAccessed.value
|
||||
: this.lastAccessed,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('DetailTrackCacheData(')
|
||||
..write('cacheKey: $cacheKey, ')
|
||||
..write('parentType: $parentType, ')
|
||||
..write('parentKey: $parentKey, ')
|
||||
..write('tracksJson: $tracksJson, ')
|
||||
..write('lastFetched: $lastFetched, ')
|
||||
..write('lastAccessed: $lastAccessed')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
cacheKey, parentType, parentKey, tracksJson, lastFetched, lastAccessed);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is DetailTrackCacheData &&
|
||||
other.cacheKey == this.cacheKey &&
|
||||
other.parentType == this.parentType &&
|
||||
other.parentKey == this.parentKey &&
|
||||
other.tracksJson == this.tracksJson &&
|
||||
other.lastFetched == this.lastFetched &&
|
||||
other.lastAccessed == this.lastAccessed);
|
||||
}
|
||||
|
||||
class DetailTrackCacheCompanion extends UpdateCompanion<DetailTrackCacheData> {
|
||||
final Value<String> cacheKey;
|
||||
final Value<String> parentType;
|
||||
final Value<String> parentKey;
|
||||
final Value<String> tracksJson;
|
||||
final Value<DateTime> lastFetched;
|
||||
final Value<DateTime> lastAccessed;
|
||||
final Value<int> rowid;
|
||||
const DetailTrackCacheCompanion({
|
||||
this.cacheKey = const Value.absent(),
|
||||
this.parentType = const Value.absent(),
|
||||
this.parentKey = const Value.absent(),
|
||||
this.tracksJson = const Value.absent(),
|
||||
this.lastFetched = const Value.absent(),
|
||||
this.lastAccessed = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
DetailTrackCacheCompanion.insert({
|
||||
required String cacheKey,
|
||||
required String parentType,
|
||||
required String parentKey,
|
||||
required String tracksJson,
|
||||
required DateTime lastFetched,
|
||||
required DateTime lastAccessed,
|
||||
this.rowid = const Value.absent(),
|
||||
}) : cacheKey = Value(cacheKey),
|
||||
parentType = Value(parentType),
|
||||
parentKey = Value(parentKey),
|
||||
tracksJson = Value(tracksJson),
|
||||
lastFetched = Value(lastFetched),
|
||||
lastAccessed = Value(lastAccessed);
|
||||
static Insertable<DetailTrackCacheData> custom({
|
||||
Expression<String>? cacheKey,
|
||||
Expression<String>? parentType,
|
||||
Expression<String>? parentKey,
|
||||
Expression<String>? tracksJson,
|
||||
Expression<DateTime>? lastFetched,
|
||||
Expression<DateTime>? lastAccessed,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (cacheKey != null) 'cache_key': cacheKey,
|
||||
if (parentType != null) 'parent_type': parentType,
|
||||
if (parentKey != null) 'parent_key': parentKey,
|
||||
if (tracksJson != null) 'tracks_json': tracksJson,
|
||||
if (lastFetched != null) 'last_fetched': lastFetched,
|
||||
if (lastAccessed != null) 'last_accessed': lastAccessed,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
|
||||
DetailTrackCacheCompanion copyWith(
|
||||
{Value<String>? cacheKey,
|
||||
Value<String>? parentType,
|
||||
Value<String>? parentKey,
|
||||
Value<String>? tracksJson,
|
||||
Value<DateTime>? lastFetched,
|
||||
Value<DateTime>? lastAccessed,
|
||||
Value<int>? rowid}) {
|
||||
return DetailTrackCacheCompanion(
|
||||
cacheKey: cacheKey ?? this.cacheKey,
|
||||
parentType: parentType ?? this.parentType,
|
||||
parentKey: parentKey ?? this.parentKey,
|
||||
tracksJson: tracksJson ?? this.tracksJson,
|
||||
lastFetched: lastFetched ?? this.lastFetched,
|
||||
lastAccessed: lastAccessed ?? this.lastAccessed,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (cacheKey.present) {
|
||||
map['cache_key'] = Variable<String>(cacheKey.value);
|
||||
}
|
||||
if (parentType.present) {
|
||||
map['parent_type'] = Variable<String>(parentType.value);
|
||||
}
|
||||
if (parentKey.present) {
|
||||
map['parent_key'] = Variable<String>(parentKey.value);
|
||||
}
|
||||
if (tracksJson.present) {
|
||||
map['tracks_json'] = Variable<String>(tracksJson.value);
|
||||
}
|
||||
if (lastFetched.present) {
|
||||
map['last_fetched'] = Variable<DateTime>(lastFetched.value);
|
||||
}
|
||||
if (lastAccessed.present) {
|
||||
map['last_accessed'] = Variable<DateTime>(lastAccessed.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('DetailTrackCacheCompanion(')
|
||||
..write('cacheKey: $cacheKey, ')
|
||||
..write('parentType: $parentType, ')
|
||||
..write('parentKey: $parentKey, ')
|
||||
..write('tracksJson: $tracksJson, ')
|
||||
..write('lastFetched: $lastFetched, ')
|
||||
..write('lastAccessed: $lastAccessed, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
_$AppDatabase(QueryExecutor e) : super(e);
|
||||
$AppDatabaseManager get managers => $AppDatabaseManager(this);
|
||||
@@ -2928,6 +3303,8 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
late final $CachedQueueTable cachedQueue = $CachedQueueTable(this);
|
||||
late final $HomeRowCacheTable homeRowCache = $HomeRowCacheTable(this);
|
||||
late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this);
|
||||
late final $DetailTrackCacheTable detailTrackCache =
|
||||
$DetailTrackCacheTable(this);
|
||||
late final Index idxRecentlyPlayedProfile = Index(
|
||||
'idx_recently_played_profile',
|
||||
'CREATE INDEX idx_recently_played_profile ON recently_played (profile_username)');
|
||||
@@ -2958,6 +3335,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
|
||||
cachedQueue,
|
||||
homeRowCache,
|
||||
searchHistory,
|
||||
detailTrackCache,
|
||||
idxRecentlyPlayedProfile,
|
||||
idxRecentlyPlayedProfilePlayed,
|
||||
idxLibraryCacheType,
|
||||
@@ -4701,6 +5079,198 @@ typedef $$SearchHistoryTableProcessedTableManager = ProcessedTableManager<
|
||||
),
|
||||
SearchHistoryData,
|
||||
PrefetchHooks Function()>;
|
||||
typedef $$DetailTrackCacheTableCreateCompanionBuilder
|
||||
= DetailTrackCacheCompanion Function({
|
||||
required String cacheKey,
|
||||
required String parentType,
|
||||
required String parentKey,
|
||||
required String tracksJson,
|
||||
required DateTime lastFetched,
|
||||
required DateTime lastAccessed,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$DetailTrackCacheTableUpdateCompanionBuilder
|
||||
= DetailTrackCacheCompanion Function({
|
||||
Value<String> cacheKey,
|
||||
Value<String> parentType,
|
||||
Value<String> parentKey,
|
||||
Value<String> tracksJson,
|
||||
Value<DateTime> lastFetched,
|
||||
Value<DateTime> lastAccessed,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
class $$DetailTrackCacheTableFilterComposer
|
||||
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
|
||||
$$DetailTrackCacheTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<String> get cacheKey => $composableBuilder(
|
||||
column: $table.cacheKey, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get parentType => $composableBuilder(
|
||||
column: $table.parentType, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get parentKey => $composableBuilder(
|
||||
column: $table.parentKey, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<String> get tracksJson => $composableBuilder(
|
||||
column: $table.tracksJson, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<DateTime> get lastFetched => $composableBuilder(
|
||||
column: $table.lastFetched, builder: (column) => ColumnFilters(column));
|
||||
|
||||
ColumnFilters<DateTime> get lastAccessed => $composableBuilder(
|
||||
column: $table.lastAccessed, builder: (column) => ColumnFilters(column));
|
||||
}
|
||||
|
||||
class $$DetailTrackCacheTableOrderingComposer
|
||||
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
|
||||
$$DetailTrackCacheTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnOrderings<String> get cacheKey => $composableBuilder(
|
||||
column: $table.cacheKey, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get parentType => $composableBuilder(
|
||||
column: $table.parentType, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get parentKey => $composableBuilder(
|
||||
column: $table.parentKey, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<String> get tracksJson => $composableBuilder(
|
||||
column: $table.tracksJson, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<DateTime> get lastFetched => $composableBuilder(
|
||||
column: $table.lastFetched, builder: (column) => ColumnOrderings(column));
|
||||
|
||||
ColumnOrderings<DateTime> get lastAccessed => $composableBuilder(
|
||||
column: $table.lastAccessed,
|
||||
builder: (column) => ColumnOrderings(column));
|
||||
}
|
||||
|
||||
class $$DetailTrackCacheTableAnnotationComposer
|
||||
extends Composer<_$AppDatabase, $DetailTrackCacheTable> {
|
||||
$$DetailTrackCacheTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
GeneratedColumn<String> get cacheKey =>
|
||||
$composableBuilder(column: $table.cacheKey, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get parentType => $composableBuilder(
|
||||
column: $table.parentType, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get parentKey =>
|
||||
$composableBuilder(column: $table.parentKey, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get tracksJson => $composableBuilder(
|
||||
column: $table.tracksJson, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get lastFetched => $composableBuilder(
|
||||
column: $table.lastFetched, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get lastAccessed => $composableBuilder(
|
||||
column: $table.lastAccessed, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$DetailTrackCacheTableTableManager extends RootTableManager<
|
||||
_$AppDatabase,
|
||||
$DetailTrackCacheTable,
|
||||
DetailTrackCacheData,
|
||||
$$DetailTrackCacheTableFilterComposer,
|
||||
$$DetailTrackCacheTableOrderingComposer,
|
||||
$$DetailTrackCacheTableAnnotationComposer,
|
||||
$$DetailTrackCacheTableCreateCompanionBuilder,
|
||||
$$DetailTrackCacheTableUpdateCompanionBuilder,
|
||||
(
|
||||
DetailTrackCacheData,
|
||||
BaseReferences<_$AppDatabase, $DetailTrackCacheTable,
|
||||
DetailTrackCacheData>
|
||||
),
|
||||
DetailTrackCacheData,
|
||||
PrefetchHooks Function()> {
|
||||
$$DetailTrackCacheTableTableManager(
|
||||
_$AppDatabase db, $DetailTrackCacheTable table)
|
||||
: super(TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
$$DetailTrackCacheTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
$$DetailTrackCacheTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
$$DetailTrackCacheTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback: ({
|
||||
Value<String> cacheKey = const Value.absent(),
|
||||
Value<String> parentType = const Value.absent(),
|
||||
Value<String> parentKey = const Value.absent(),
|
||||
Value<String> tracksJson = const Value.absent(),
|
||||
Value<DateTime> lastFetched = const Value.absent(),
|
||||
Value<DateTime> lastAccessed = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
DetailTrackCacheCompanion(
|
||||
cacheKey: cacheKey,
|
||||
parentType: parentType,
|
||||
parentKey: parentKey,
|
||||
tracksJson: tracksJson,
|
||||
lastFetched: lastFetched,
|
||||
lastAccessed: lastAccessed,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback: ({
|
||||
required String cacheKey,
|
||||
required String parentType,
|
||||
required String parentKey,
|
||||
required String tracksJson,
|
||||
required DateTime lastFetched,
|
||||
required DateTime lastAccessed,
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) =>
|
||||
DetailTrackCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
parentType: parentType,
|
||||
parentKey: parentKey,
|
||||
tracksJson: tracksJson,
|
||||
lastFetched: lastFetched,
|
||||
lastAccessed: lastAccessed,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
));
|
||||
}
|
||||
|
||||
typedef $$DetailTrackCacheTableProcessedTableManager = ProcessedTableManager<
|
||||
_$AppDatabase,
|
||||
$DetailTrackCacheTable,
|
||||
DetailTrackCacheData,
|
||||
$$DetailTrackCacheTableFilterComposer,
|
||||
$$DetailTrackCacheTableOrderingComposer,
|
||||
$$DetailTrackCacheTableAnnotationComposer,
|
||||
$$DetailTrackCacheTableCreateCompanionBuilder,
|
||||
$$DetailTrackCacheTableUpdateCompanionBuilder,
|
||||
(
|
||||
DetailTrackCacheData,
|
||||
BaseReferences<_$AppDatabase, $DetailTrackCacheTable,
|
||||
DetailTrackCacheData>
|
||||
),
|
||||
DetailTrackCacheData,
|
||||
PrefetchHooks Function()>;
|
||||
|
||||
class $AppDatabaseManager {
|
||||
final _$AppDatabase _db;
|
||||
@@ -4723,4 +5293,6 @@ class $AppDatabaseManager {
|
||||
$$HomeRowCacheTableTableManager(_db, _db.homeRowCache);
|
||||
$$SearchHistoryTableTableManager get searchHistory =>
|
||||
$$SearchHistoryTableTableManager(_db, _db.searchHistory);
|
||||
$$DetailTrackCacheTableTableManager get detailTrackCache =>
|
||||
$$DetailTrackCacheTableTableManager(_db, _db.detailTrackCache);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ void main() {
|
||||
androidNotificationIcon: 'drawable/ic_notification',
|
||||
androidShowNotificationBadge: false,
|
||||
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
||||
androidBrowsableRootExtras: {
|
||||
'android.media.browse.SEARCH_SUPPORTED': true,
|
||||
},
|
||||
),
|
||||
);
|
||||
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
||||
@@ -144,6 +147,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
_musicProvider = MusicAssistantProvider();
|
||||
_themeProvider = ThemeProvider();
|
||||
_localeProvider = LocaleProvider();
|
||||
audioHandler.setProvider(_musicProvider);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initHardwareVolumeControl();
|
||||
// Listen to player selection changes to toggle volume interception
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,14 +17,14 @@ class NavigationProvider extends ChangeNotifier {
|
||||
if (previousIndex != index) {
|
||||
_selectedIndex = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Trigger search focus callback when switching to search tab
|
||||
if (index == 2 && onSearchTabSelected != null) {
|
||||
// Use post-frame callback to ensure SearchScreen is built first
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
onSearchTabSelected?.call();
|
||||
});
|
||||
}
|
||||
// Trigger search focus callback when switching to or re-tapping search tab
|
||||
if (index == 2 && onSearchTabSelected != null) {
|
||||
// Use post-frame callback to ensure SearchScreen is built first
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
onSearchTabSelected?.call();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -573,6 +573,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
widget.album.provider,
|
||||
widget.album.itemId,
|
||||
forceRefresh: cachedTracks != null, // Force refresh if we had cache
|
||||
album: widget.album,
|
||||
);
|
||||
|
||||
// 3. Update if we got different data
|
||||
|
||||
@@ -680,6 +680,19 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No cache yet - show library albums from memory instantly (no API call)
|
||||
final memoryAlbums = provider.getArtistAlbumsFromLibrary(widget.artist.name);
|
||||
if (memoryAlbums.isNotEmpty && mounted) {
|
||||
final libraryAlbums = memoryAlbums.where((a) => a.inLibrary).toList();
|
||||
final providerOnlyAlbums = memoryAlbums.where((a) => !a.inLibrary).toList();
|
||||
setState(() {
|
||||
_albums = libraryAlbums;
|
||||
_providerAlbums = providerOnlyAlbums;
|
||||
_sortAlbums();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch fresh data in background (silent refresh)
|
||||
@@ -690,13 +703,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
|
||||
);
|
||||
|
||||
if (mounted && allAlbums.isNotEmpty) {
|
||||
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
|
||||
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
|
||||
|
||||
// Check if data actually changed
|
||||
final albumsChanged = _albums.length != allAlbums.where((a) => a.inLibrary).length;
|
||||
|
||||
if (albumsChanged || _albums.isEmpty) {
|
||||
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
|
||||
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
|
||||
final changed = _albums.length != libraryAlbums.length ||
|
||||
_providerAlbums.length != providerOnlyAlbums.length;
|
||||
|
||||
if (changed) {
|
||||
setState(() {
|
||||
_albums = libraryAlbums;
|
||||
_providerAlbums = providerOnlyAlbums;
|
||||
|
||||
@@ -68,53 +68,70 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
markDetailScreenEntered(context);
|
||||
_extractColors(); // Extract colors immediately - async so won't block
|
||||
}
|
||||
// Load full audiobook details with chapters
|
||||
_loadAudiobookDetails();
|
||||
});
|
||||
|
||||
// Defer detail loading until after hero animation completes
|
||||
// This prevents setState with new data during animation → jank
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
_loadAudiobookDetails();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAudiobookDetails() async {
|
||||
if (_isLoadingDetails) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingDetails = true;
|
||||
});
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// 1. Show cached data immediately (if available)
|
||||
final cached = maProvider.getCachedAudiobookDetail(
|
||||
widget.audiobook.provider,
|
||||
widget.audiobook.itemId,
|
||||
);
|
||||
if (cached != null && mounted) {
|
||||
_logger.log('📚 Showing cached audiobook: ${cached.name}, chapters: ${cached.chapters?.length ?? 0}');
|
||||
setState(() {
|
||||
_fullAudiobook = cached;
|
||||
_isLoadingDetails = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoadingDetails = true;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Fetch fresh data in background
|
||||
try {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final fullBook = await maProvider.getAudiobookDetailsWithCache(
|
||||
widget.audiobook.provider,
|
||||
widget.audiobook.itemId,
|
||||
forceRefresh: cached != null,
|
||||
);
|
||||
|
||||
if (maProvider.api != null) {
|
||||
final fullBook = await maProvider.api!.getAudiobookDetails(
|
||||
widget.audiobook.provider,
|
||||
widget.audiobook.itemId,
|
||||
);
|
||||
|
||||
if (mounted && fullBook != null) {
|
||||
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
|
||||
setState(() {
|
||||
_fullAudiobook = fullBook;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
if (mounted && fullBook != null) {
|
||||
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
|
||||
setState(() {
|
||||
_fullAudiobook = fullBook;
|
||||
_isLoadingDetails = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error loading audiobook details: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDetails = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted && _isLoadingDetails) {
|
||||
setState(() {
|
||||
_isLoadingDetails = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractColors() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
|
||||
final imageUrl = maProvider.getImageUrl(_audiobook, size: 512);
|
||||
if (imageUrl == null) return;
|
||||
|
||||
try {
|
||||
@@ -480,7 +497,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
|
||||
final providerImageUrl = maProvider.getImageUrl(_audiobook, size: 512);
|
||||
// Use initialImageUrl as fallback for seamless hero animation
|
||||
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
|
||||
|
||||
|
||||
@@ -44,24 +44,30 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
// Extracted colors from book covers
|
||||
List<Color> _extractedColors = [];
|
||||
|
||||
// Cover URLs - start with initial covers, update when books load
|
||||
// Cover URLs - set once from initialCovers or first book load, then frozen
|
||||
List<String> _coverUrls = [];
|
||||
|
||||
// Cached cover widget - built once, never rebuilt
|
||||
Widget? _cachedCoverWidget;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Use initial covers immediately for smooth Hero animation
|
||||
if (widget.initialCovers != null && widget.initialCovers!.isNotEmpty) {
|
||||
_coverUrls = List.from(widget.initialCovers!);
|
||||
// Start color extraction from initial covers
|
||||
_extractColorsFromUrls(widget.initialCovers!);
|
||||
}
|
||||
_loadViewPreferences();
|
||||
// Defer loading until after first frame to ensure UI renders first
|
||||
// Defer color extraction and loading until after hero animation completes
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_loadSeriesBooks();
|
||||
}
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
if (_coverUrls.isNotEmpty) {
|
||||
_extractColorsFromUrls(_coverUrls);
|
||||
}
|
||||
_loadSeriesBooks();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,35 +164,68 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
|
||||
Future<void> _loadSeriesBooks() async {
|
||||
_logger.log('📚 SeriesScreen _loadSeriesBooks START');
|
||||
try {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// 1. Show cached data immediately (if available)
|
||||
final cached = maProvider.getCachedSeriesAudiobooks(widget.series.id);
|
||||
if (cached != null && cached.isNotEmpty && mounted) {
|
||||
_logger.log('📚 Showing ${cached.length} cached series books');
|
||||
_applyBooks(cached, maProvider);
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
_logger.log('📚 SeriesScreen setState done, getting provider...');
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
_logger.log('📚 SeriesScreen got provider, api=${maProvider.api != null}');
|
||||
}
|
||||
|
||||
// 2. Fetch fresh data in background
|
||||
try {
|
||||
if (maProvider.api == null) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.notConnected;
|
||||
_isLoading = false;
|
||||
});
|
||||
if (cached == null || cached.isEmpty) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.notConnected;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.log('📚 SeriesScreen calling getSeriesAudiobooks: path=${widget.series.id}');
|
||||
final books = await maProvider.api!.getSeriesAudiobooks(widget.series.id);
|
||||
final books = await maProvider.getSeriesAudiobooksWithCache(
|
||||
widget.series.id,
|
||||
forceRefresh: cached != null && cached.isNotEmpty,
|
||||
);
|
||||
_logger.log('📚 SeriesScreen got ${books.length} books');
|
||||
|
||||
// Debug: Log sequence data for each book
|
||||
for (final book in books) {
|
||||
_logger.log('📚 Book: "${book.name}" | position=${book.position} | browseOrder=${book.browseOrder} | sortName=${book.sortName} | seq=${book.seriesSequence} | metadata.series=${book.metadata?['series']}');
|
||||
if (mounted && books.isNotEmpty) {
|
||||
final changed = _audiobooks.length != books.length;
|
||||
if (changed || _audiobooks.isEmpty) {
|
||||
_applyBooks(books, maProvider);
|
||||
}
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_logger.log('📚 SeriesScreen error: $e');
|
||||
_logger.log('📚 SeriesScreen stack: $stack');
|
||||
if (mounted && _audiobooks.isEmpty) {
|
||||
setState(() {
|
||||
_error = 'Failed to load books: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Update cover URLs from loaded books
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyBooks(List<Audiobook> books, MusicAssistantProvider maProvider) {
|
||||
// Only populate covers if we have none yet (initialCovers was empty)
|
||||
final needsCovers = _coverUrls.isEmpty;
|
||||
|
||||
setState(() {
|
||||
_audiobooks = books;
|
||||
_sortAudiobooks();
|
||||
if (needsCovers) {
|
||||
final newCovers = <String>[];
|
||||
for (final book in books.take(9)) {
|
||||
final imageUrl = maProvider.getImageUrl(book);
|
||||
@@ -194,34 +233,15 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
newCovers.add(imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_audiobooks = books;
|
||||
_sortAudiobooks(); // Sort inside setState so UI updates
|
||||
// Update covers if we got new ones (may have more than initial)
|
||||
if (newCovers.isNotEmpty) {
|
||||
_coverUrls = newCovers;
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
// Debug: Log sorted order
|
||||
_logger.log('📚 Sorted order ($_sortOrder):');
|
||||
for (final book in _audiobooks) {
|
||||
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
|
||||
if (newCovers.isNotEmpty) {
|
||||
_coverUrls = newCovers;
|
||||
}
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Extract colors from book covers asynchronously
|
||||
_extractCoverColors(maProvider);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_logger.log('📚 SeriesScreen error: $e');
|
||||
_logger.log('📚 SeriesScreen stack: $stack');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Failed to load books: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
if (needsCovers && _extractedColors.isEmpty) {
|
||||
_extractCoverColors(maProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,25 +323,31 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
// Series cover collage - responsive size
|
||||
Hero(
|
||||
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
// Shadow container outside Hero to avoid widget tree mismatch
|
||||
Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Hero child matches source: RepaintBoundary > AspectRatio > ClipRRect
|
||||
child: Hero(
|
||||
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
||||
child: RepaintBoundary(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _buildSeriesCover(colorScheme, maProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _buildSeriesCover(colorScheme, maProvider),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -515,6 +541,9 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Return cached cover widget if already built (prevents rebuilds from unrelated setState)
|
||||
if (_cachedCoverWidget != null) return _cachedCoverWidget!;
|
||||
|
||||
final covers = _coverUrls;
|
||||
|
||||
// Dynamic grid size based on number of covers (matches library screen)
|
||||
@@ -538,7 +567,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
}
|
||||
|
||||
// Build grid using Column/Row for proper sizing (no scrolling issues)
|
||||
return Column(
|
||||
// Cache the widget so it never rebuilds from unrelated setState calls
|
||||
_cachedCoverWidget = Column(
|
||||
children: List.generate(gridSize, (row) {
|
||||
return Expanded(
|
||||
child: Row(
|
||||
@@ -549,8 +579,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
return Expanded(
|
||||
child: coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
key: ValueKey(coverUrl),
|
||||
imageUrl: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Container(
|
||||
@@ -572,6 +605,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
);
|
||||
}),
|
||||
);
|
||||
return _cachedCoverWidget!;
|
||||
}
|
||||
|
||||
/// Builds an empty cell with either a solid color or a nested grid
|
||||
@@ -689,6 +723,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
|
||||
@@ -148,11 +148,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
/// Moved here from GlobalPlayerOverlay so it has proper Overlay ancestry
|
||||
/// (required by BottomNavigationBar's internal Tooltip widgets).
|
||||
Widget? _buildBottomNavigationBar(BuildContext context, ColorScheme colorScheme) {
|
||||
// Hide nav bar when not connected
|
||||
final isConnected = context.select<MusicAssistantProvider, bool>(
|
||||
(p) => p.isConnected,
|
||||
// Hide nav bar when not connected and no cached players
|
||||
final showNavBar = context.select<MusicAssistantProvider, bool>(
|
||||
(p) => p.isConnected || p.hasCachedPlayers,
|
||||
);
|
||||
if (!isConnected) return null;
|
||||
if (!showNavBar) return null;
|
||||
|
||||
return Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player/mini_player_content.dart' show MiniPlayerLayout;
|
||||
import '../widgets/player_selector.dart';
|
||||
import '../widgets/album_row.dart';
|
||||
import '../widgets/artist_row.dart';
|
||||
@@ -34,7 +33,6 @@ class NewHomeScreen extends StatefulWidget {
|
||||
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
|
||||
static final _logger = DebugLogger();
|
||||
final ValueNotifier<int> _refreshSignal = ValueNotifier<int>(0);
|
||||
double? _cachedRowHeight; // Cached to avoid size jumps when nav bar toggles
|
||||
// Main rows (default on)
|
||||
bool _showRecentAlbums = true;
|
||||
bool _showDiscoverArtists = true;
|
||||
@@ -432,6 +430,28 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom fade gradient to mask content behind mini player (4+ rows only)
|
||||
if (_countEnabledRows() >= 4)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.miniPlayerHeight,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.background.withOpacity(0.0),
|
||||
colorScheme.background.withOpacity(0.85),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -482,9 +502,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
builder: (context, constraints) {
|
||||
// Each row is always 1/3 of screen height
|
||||
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
|
||||
// With extendBody: true, constraints include the area behind the nav bar.
|
||||
// Only subtract mini player space (not nav bar) since nav bar is Scaffold chrome.
|
||||
final miniPlayerSpace = MiniPlayerLayout.height + 12.0 + 22.0; // 72 + 12 + 22 = 106
|
||||
// Reserve space for the mini player overlay at the bottom.
|
||||
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0;
|
||||
final availableHeight = constraints.maxHeight - miniPlayerSpace;
|
||||
|
||||
// Account for margins between rows (2px each, 2 margins for 3 rows).
|
||||
@@ -493,19 +512,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
const marginSize = 2.0;
|
||||
final enabledRows = _countEnabledRows();
|
||||
const marginsInView = 2 * marginSize; // always 2 margins for 3-row layout
|
||||
final candidateRowHeight = (availableHeight - marginsInView) / 3;
|
||||
|
||||
// Cache rowHeight to prevent size jumps when the bottom nav bar toggles
|
||||
// on connect/disconnect. The nav bar hides when disconnected, giving more
|
||||
// vertical space and taller rows. When it reappears, rows shrink visibly.
|
||||
// Fix: always ratchet down to the smaller (nav-bar-present) value, and
|
||||
// only allow increases on large changes like screen rotation.
|
||||
if (_cachedRowHeight == null || (candidateRowHeight - _cachedRowHeight!).abs() > 50) {
|
||||
_cachedRowHeight = candidateRowHeight;
|
||||
} else if (candidateRowHeight < _cachedRowHeight!) {
|
||||
_cachedRowHeight = candidateRowHeight;
|
||||
}
|
||||
final rowHeight = _cachedRowHeight!;
|
||||
final rowHeight = (availableHeight - marginsInView) / 3 + 2;
|
||||
|
||||
// Use Android 12+ stretch overscroll effect
|
||||
return NotificationListener<ScrollNotification>(
|
||||
@@ -527,8 +534,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
// For ≤3 rows: no padding needed (rows + miniPlayerSpace = viewport)
|
||||
// For 4+ rows: pad by miniPlayerSpace so last row scrolls above mini player
|
||||
// For ≤3 rows: no padding needed (rows fit within viewport)
|
||||
// For 4+ rows: pad so last row scrolls above mini player
|
||||
padding: enabledRows >= 4
|
||||
? EdgeInsets.only(bottom: miniPlayerSpace)
|
||||
: EdgeInsets.zero,
|
||||
|
||||
@@ -159,10 +159,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
// PERF: Separate ValueNotifier for efficient UI updates (RestorableInt doesn't implement ValueListenable)
|
||||
final ValueNotifier<int> _tabIndexNotifier = ValueNotifier<int>(0);
|
||||
|
||||
// Scroll-to-hide filter bars
|
||||
bool _isFilterBarVisible = true;
|
||||
double _lastScrollOffset = 0;
|
||||
static const double _scrollThreshold = 10.0;
|
||||
// Scroll-to-hide filter bars (0.0 = hidden, 1.0 = visible)
|
||||
final ValueNotifier<double> _filterBarVisibility = ValueNotifier<double>(1.0);
|
||||
bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging
|
||||
|
||||
// Options menu overlay - stored to close on navigation
|
||||
@@ -859,17 +857,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
final currentOffset = notification.metrics.pixels;
|
||||
final delta = currentOffset - _lastScrollOffset;
|
||||
|
||||
if (delta.abs() > _scrollThreshold) {
|
||||
final shouldShow = delta < 0 || currentOffset <= 0;
|
||||
if (shouldShow != _isFilterBarVisible) {
|
||||
setState(() {
|
||||
_isFilterBarVisible = shouldShow;
|
||||
});
|
||||
}
|
||||
_lastScrollOffset = currentOffset;
|
||||
final delta = notification.scrollDelta ?? 0.0;
|
||||
if (notification.metrics.pixels <= 0) {
|
||||
_filterBarVisibility.value = 1.0;
|
||||
} else {
|
||||
_filterBarVisibility.value = (_filterBarVisibility.value - delta / _filterRowHeight).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -911,13 +903,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
}
|
||||
|
||||
void _onLetterScrollbarDragChanged(bool isDragging) {
|
||||
setState(() {
|
||||
_isLetterScrollbarDragging = isDragging;
|
||||
// Show the filter bar when starting to drag
|
||||
if (isDragging) {
|
||||
_isFilterBarVisible = true;
|
||||
}
|
||||
});
|
||||
_isLetterScrollbarDragging = isDragging;
|
||||
// Show the filter bar when starting to drag
|
||||
if (isDragging) {
|
||||
_filterBarVisibility.value = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
void _animateToCategory(int index) {
|
||||
@@ -942,6 +932,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
_pageController.dispose();
|
||||
_selectedTabIndex.dispose();
|
||||
_tabIndexNotifier.dispose();
|
||||
_filterBarVisibility.dispose();
|
||||
_artistsScrollController.dispose();
|
||||
_albumsScrollController.dispose();
|
||||
_playlistsScrollController.dispose();
|
||||
@@ -1449,7 +1440,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
child: Column(
|
||||
children: [
|
||||
// Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible)
|
||||
_buildFilterRows(colorScheme, l10n, showLibraryTypeRow: _isFilterBarVisible),
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: _filterBarVisibility,
|
||||
builder: (context, visibility, _) {
|
||||
return _buildFilterRows(colorScheme, l10n, barVisibility: visibility);
|
||||
},
|
||||
),
|
||||
// Connecting banner when showing cached data
|
||||
// Hide when we have cached players - UI is functional during background reconnect
|
||||
if (!isConnected && syncService.hasCache && !context.read<MusicAssistantProvider>().hasCachedPlayers)
|
||||
@@ -1546,23 +1542,25 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
// Consistent height for filter rows
|
||||
static const double _filterRowHeight = 36.0;
|
||||
|
||||
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required bool showLibraryTypeRow}) {
|
||||
Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required double barVisibility}) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Row 1: Media type chips (hides when scrolling)
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
height: showLibraryTypeRow ? _filterRowHeight : 0,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: _buildMediaTypeChips(colorScheme, l10n),
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
heightFactor: barVisibility,
|
||||
child: SizedBox(
|
||||
height: _filterRowHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: _buildMediaTypeChips(colorScheme, l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showLibraryTypeRow) const SizedBox(height: 12), // Space between rows
|
||||
SizedBox(height: 12 * barVisibility), // Space between rows
|
||||
// Row 2: Sub-category chips (left) + action buttons (right) - always visible
|
||||
SizedBox(
|
||||
height: _filterRowHeight,
|
||||
@@ -4914,7 +4912,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
|
||||
try {
|
||||
// Start radio from this track
|
||||
await maProvider.api?.playRadio(player.playerId, track);
|
||||
await maProvider.playRadio(player.playerId, track);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -6079,8 +6077,9 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
|
||||
|
||||
// Position menu below and to the left of the button
|
||||
final menuWidth = 220.0;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final left = (widget.position.dx + widget.buttonSize.width - menuWidth).clamp(8.0, screenWidth - menuWidth - 8);
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final bottomInset = BottomSpacing.withMiniPlayer + MediaQuery.of(context).viewPadding.bottom;
|
||||
final left = (widget.position.dx + widget.buttonSize.width - menuWidth).clamp(8.0, screenSize.width - menuWidth - 8);
|
||||
final top = widget.position.dy + widget.buttonSize.height + 4;
|
||||
|
||||
return Stack(
|
||||
@@ -6110,7 +6109,7 @@ class _OptionsMenuOverlayState extends State<_OptionsMenuOverlay>
|
||||
child: Container(
|
||||
width: menuWidth,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height - top - 50,
|
||||
maxHeight: screenSize.height - top - bottomInset,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
|
||||
@@ -59,7 +59,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
super.initState();
|
||||
_isFavorite = widget.playlist.favorite ?? false;
|
||||
_isInLibrary = widget.playlist.inLibrary;
|
||||
_loadTracks();
|
||||
|
||||
// Mark that we're on a detail screen and extract colors immediately
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -68,6 +67,15 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
_extractColors(); // Extract colors immediately - async so won't block
|
||||
}
|
||||
});
|
||||
|
||||
// Defer track loading until after hero animation completes
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
_loadTracks();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _extractColors() async {
|
||||
|
||||
@@ -61,7 +61,15 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
markDetailScreenEntered(context);
|
||||
_extractColors(); // Extract colors immediately - async so won't block
|
||||
}
|
||||
_loadEpisodes();
|
||||
});
|
||||
|
||||
// Defer episode loading until after hero animation completes
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
_loadEpisodes();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -380,54 +388,62 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
Future<void> _loadEpisodes() async {
|
||||
if (_isLoadingEpisodes) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingEpisodes = true;
|
||||
});
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// 1. Show cached episodes immediately (if available)
|
||||
final cached = maProvider.getCachedPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
if (cached != null && cached.isNotEmpty && mounted) {
|
||||
_logger.log('🎙️ Showing ${cached.length} cached episodes for ${widget.podcast.name}');
|
||||
setState(() {
|
||||
_episodes = cached;
|
||||
_sortEpisodes();
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = true;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Fetch fresh data in background
|
||||
try {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api != null) {
|
||||
List<MediaItem> episodes = [];
|
||||
List<MediaItem> episodes = [];
|
||||
|
||||
// Try loading with the podcast's own ID and provider first
|
||||
try {
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Primary episode load failed: $e');
|
||||
}
|
||||
// Try loading with the podcast's own ID and provider first
|
||||
episodes = await maProvider.getPodcastEpisodesWithCache(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
forceRefresh: cached != null && cached.isNotEmpty,
|
||||
);
|
||||
|
||||
// If that failed and we have provider mappings, try those
|
||||
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
|
||||
for (final mapping in widget.podcast.providerMappings!) {
|
||||
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
|
||||
try {
|
||||
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
mapping.itemId,
|
||||
provider: mapping.providerInstance,
|
||||
);
|
||||
if (episodes.isNotEmpty) {
|
||||
_logger.log('🎙️ Loaded episodes via provider mapping');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Provider mapping failed: $e');
|
||||
// If that failed and we have provider mappings, try those
|
||||
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
|
||||
for (final mapping in widget.podcast.providerMappings!) {
|
||||
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
|
||||
try {
|
||||
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
|
||||
episodes = await maProvider.getPodcastEpisodesWithCache(
|
||||
mapping.itemId,
|
||||
provider: mapping.providerInstance,
|
||||
);
|
||||
if (episodes.isNotEmpty) {
|
||||
_logger.log('🎙️ Loaded episodes via provider mapping');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Provider mapping failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
if (mounted && episodes.isNotEmpty) {
|
||||
final changed = _episodes.length != episodes.length;
|
||||
if (changed || _episodes.isEmpty) {
|
||||
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
|
||||
// Debug: Log first episode metadata to see available fields
|
||||
if (episodes.isNotEmpty) {
|
||||
final first = episodes.first;
|
||||
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
|
||||
_logger.log('🎙️ First episode metadata: ${first.metadata}');
|
||||
}
|
||||
setState(() {
|
||||
_episodes = episodes;
|
||||
_sortEpisodes();
|
||||
@@ -436,12 +452,12 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Error loading episodes: $e');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted && _isLoadingEpisodes) {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,10 +130,8 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
// Advanced search scorer with fuzzy matching, stopword removal, etc.
|
||||
final SearchScorer _searchScorer = SearchScorer();
|
||||
|
||||
// Scroll-to-hide search bar (vertical scroll only)
|
||||
bool _isSearchBarVisible = true;
|
||||
double _lastVerticalScrollOffset = 0;
|
||||
static const double _scrollThreshold = 10.0;
|
||||
// Scroll-to-hide search bar (0.0 = hidden, 1.0 = visible)
|
||||
final ValueNotifier<double> _searchBarVisibility = ValueNotifier<double>(1.0);
|
||||
|
||||
// Filter chip position tracking for animated sliding highlight
|
||||
final Map<String, GlobalKey> _filterKeys = {};
|
||||
@@ -314,6 +312,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
|
||||
void requestFocus() {
|
||||
if (mounted) {
|
||||
_searchBarVisibility.value = 1.0;
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
@@ -332,6 +331,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_pageController.dispose();
|
||||
_filterScrollController.dispose();
|
||||
_activeFilterNotifier.dispose();
|
||||
_searchBarVisibility.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -343,17 +343,12 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
final currentOffset = notification.metrics.pixels;
|
||||
final delta = currentOffset - _lastVerticalScrollOffset;
|
||||
|
||||
if (delta.abs() > _scrollThreshold) {
|
||||
final shouldShow = delta < 0 || currentOffset <= 0;
|
||||
if (shouldShow != _isSearchBarVisible) {
|
||||
setState(() {
|
||||
_isSearchBarVisible = shouldShow;
|
||||
});
|
||||
}
|
||||
_lastVerticalScrollOffset = currentOffset;
|
||||
final delta = notification.scrollDelta ?? 0.0;
|
||||
const maxHeight = kToolbarHeight + 16;
|
||||
if (notification.metrics.pixels <= 0) {
|
||||
_searchBarVisibility.value = 1.0;
|
||||
} else {
|
||||
_searchBarVisibility.value = (_searchBarVisibility.value - delta / maxHeight).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -530,16 +525,23 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Animated search bar - hides on scroll down
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
height: _isSearchBarVisible ? kToolbarHeight + 16 : 0,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
// Search bar - slides away with scroll
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: _searchBarVisibility,
|
||||
builder: (context, visibility, child) {
|
||||
return ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
heightFactor: visibility,
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight + 16,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Search field container
|
||||
Expanded(
|
||||
@@ -643,6 +645,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Main content
|
||||
Expanded(
|
||||
@@ -817,6 +820,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
child: ListView.builder(
|
||||
// PERF: Use key to preserve scroll position per filter
|
||||
key: PageStorageKey('list_$filterForPage'),
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, BottomSpacing.navBarOnly),
|
||||
cacheExtent: 1000,
|
||||
addAutomaticKeepAlives: false,
|
||||
@@ -1196,6 +1200,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1238,6 +1243,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
onTap: () {
|
||||
removeFocus();
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -1266,6 +1272,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1344,6 +1351,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
removeFocus();
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -1372,6 +1380,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1440,7 +1449,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _playTrack(track),
|
||||
onTap: () {
|
||||
removeFocus();
|
||||
_playTrack(track);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1458,6 +1470,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1546,6 +1559,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
removeFocus();
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -1576,6 +1590,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1651,7 +1666,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
// Update adaptive colors before navigation
|
||||
removeFocus();
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -1678,6 +1693,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1726,7 +1742,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () => _playRadioStation(radio),
|
||||
onTap: () {
|
||||
removeFocus();
|
||||
_playRadioStation(radio);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1744,6 +1763,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
removeFocus();
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
@@ -1800,7 +1820,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
// Update adaptive colors before navigation
|
||||
removeFocus();
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,9 @@ class CacheService {
|
||||
final Map<String, Track?> _playerTrackCache = {};
|
||||
final Map<String, DateTime> _playerTrackCacheTime = {};
|
||||
|
||||
// Last-known track cache (survives idle-out, for resume after Chromecast idle)
|
||||
final Map<String, Track> _lastKnownTrackCache = {};
|
||||
|
||||
// ============================================================================
|
||||
// HOME SCREEN ROW CACHING
|
||||
// ============================================================================
|
||||
@@ -319,6 +322,7 @@ class CacheService {
|
||||
_albumTracksCacheTime[cacheKey] = DateTime.now();
|
||||
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
|
||||
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
|
||||
_persistDetailTracks('album', cacheKey, tracks);
|
||||
}
|
||||
|
||||
/// Invalidate album tracks cache for a specific album
|
||||
@@ -353,6 +357,7 @@ class CacheService {
|
||||
_playlistTracksCacheTime[cacheKey] = DateTime.now();
|
||||
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
|
||||
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
|
||||
_persistDetailTracks('playlist', cacheKey, tracks);
|
||||
}
|
||||
|
||||
/// Invalidate playlist tracks cache for a specific playlist
|
||||
@@ -484,6 +489,14 @@ class CacheService {
|
||||
_playerTrackCacheTime.remove(playerId);
|
||||
}
|
||||
|
||||
/// Get last-known track for a player (survives idle-out)
|
||||
Track? getLastKnownTrack(String playerId) => _lastKnownTrackCache[playerId];
|
||||
|
||||
/// Save last-known track for a player (only called when a real track is playing/paused)
|
||||
void setLastKnownTrack(String playerId, Track track) {
|
||||
_lastKnownTrackCache[playerId] = track;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLEAR ALL
|
||||
// ============================================================================
|
||||
@@ -500,7 +513,21 @@ class CacheService {
|
||||
_searchCacheTime.clear();
|
||||
_playerTrackCache.clear();
|
||||
_playerTrackCacheTime.clear();
|
||||
_audiobookDetailCache.clear();
|
||||
_audiobookDetailCacheTime.clear();
|
||||
_podcastEpisodesCache.clear();
|
||||
_podcastEpisodesCacheTime.clear();
|
||||
_logger.log('🗑️ All detail caches cleared');
|
||||
// Also clear DB detail cache
|
||||
() async {
|
||||
try {
|
||||
if (DatabaseService.instance.isInitialized) {
|
||||
await DatabaseService.instance.clearDetailTrackCache();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to clear detail track DB cache: $e');
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
/// Clear all caches
|
||||
@@ -644,4 +671,177 @@ class CacheService {
|
||||
_logger.log('🗑️ LRU evicted ${keysToRemove.length} cache entries');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIOBOOK DETAIL CACHING
|
||||
// ============================================================================
|
||||
|
||||
final Map<String, String> _audiobookDetailCache = {}; // cacheKey -> JSON
|
||||
final Map<String, DateTime> _audiobookDetailCacheTime = {};
|
||||
|
||||
bool isAudiobookDetailCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final cacheTime = _audiobookDetailCacheTime[cacheKey];
|
||||
return _audiobookDetailCache.containsKey(cacheKey) &&
|
||||
cacheTime != null &&
|
||||
DateTime.now().difference(cacheTime) < const Duration(minutes: 5);
|
||||
}
|
||||
|
||||
String? getCachedAudiobookDetail(String cacheKey) => _audiobookDetailCache[cacheKey];
|
||||
|
||||
void setCachedAudiobookDetail(String cacheKey, String audiobookJson) {
|
||||
_audiobookDetailCache[cacheKey] = audiobookJson;
|
||||
_audiobookDetailCacheTime[cacheKey] = DateTime.now();
|
||||
_evictOldestEntries(_audiobookDetailCache, _audiobookDetailCacheTime, _maxDetailCacheSize);
|
||||
_logger.log('✅ Cached audiobook detail for $cacheKey');
|
||||
_persistDetailData('audiobook', cacheKey, audiobookJson);
|
||||
}
|
||||
|
||||
Future<String?> getAudiobookDetailWithDbFallback(String cacheKey) async {
|
||||
final memCached = _audiobookDetailCache[cacheKey];
|
||||
if (memCached != null) return memCached;
|
||||
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return null;
|
||||
final dbResult = await DatabaseService.instance.getDetailTracks('audiobook', cacheKey);
|
||||
if (dbResult == null) return null;
|
||||
|
||||
_audiobookDetailCache[cacheKey] = dbResult.tracksJson;
|
||||
_audiobookDetailCacheTime[cacheKey] = dbResult.lastFetched;
|
||||
_logger.log('📦 Loaded audiobook detail from database for $cacheKey');
|
||||
return dbResult.tracksJson;
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load audiobook detail from database: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PODCAST EPISODE CACHING
|
||||
// ============================================================================
|
||||
|
||||
final Map<String, List<MediaItem>> _podcastEpisodesCache = {};
|
||||
final Map<String, DateTime> _podcastEpisodesCacheTime = {};
|
||||
|
||||
bool isPodcastEpisodesCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final cacheTime = _podcastEpisodesCacheTime[cacheKey];
|
||||
return _podcastEpisodesCache.containsKey(cacheKey) &&
|
||||
cacheTime != null &&
|
||||
DateTime.now().difference(cacheTime) < const Duration(minutes: 5);
|
||||
}
|
||||
|
||||
List<MediaItem>? getCachedPodcastEpisodes(String cacheKey) => _podcastEpisodesCache[cacheKey];
|
||||
|
||||
void setCachedPodcastEpisodes(String cacheKey, List<MediaItem> episodes) {
|
||||
_podcastEpisodesCache[cacheKey] = episodes;
|
||||
_podcastEpisodesCacheTime[cacheKey] = DateTime.now();
|
||||
_evictOldestEntries(_podcastEpisodesCache, _podcastEpisodesCacheTime, _maxDetailCacheSize);
|
||||
_logger.log('✅ Cached ${episodes.length} episodes for podcast $cacheKey');
|
||||
_persistDetailMediaItems('podcast', cacheKey, episodes);
|
||||
}
|
||||
|
||||
Future<List<MediaItem>?> getPodcastEpisodesWithDbFallback(String cacheKey) async {
|
||||
final memCached = _podcastEpisodesCache[cacheKey];
|
||||
if (memCached != null) return memCached;
|
||||
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return null;
|
||||
final dbResult = await DatabaseService.instance.getDetailTracks('podcast', cacheKey);
|
||||
if (dbResult == null) return null;
|
||||
|
||||
final episodes = (jsonDecode(dbResult.tracksJson) as List)
|
||||
.map((json) => MediaItem.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
_podcastEpisodesCache[cacheKey] = episodes;
|
||||
_podcastEpisodesCacheTime[cacheKey] = dbResult.lastFetched;
|
||||
_logger.log('📦 Loaded ${episodes.length} podcast episodes from database for $cacheKey');
|
||||
return episodes;
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load podcast episodes from database: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATABASE PERSISTENCE FOR DETAIL TRACKS
|
||||
// ============================================================================
|
||||
|
||||
/// Persist detail tracks to database (fire-and-forget)
|
||||
void _persistDetailTracks(String parentType, String cacheKey, List<Track> tracks) {
|
||||
_persistDetailData(parentType, cacheKey, jsonEncode(tracks.map((t) => t.toJson()).toList()));
|
||||
}
|
||||
|
||||
/// Persist detail media items to database (fire-and-forget)
|
||||
void _persistDetailMediaItems(String parentType, String cacheKey, List<MediaItem> items) {
|
||||
_persistDetailData(parentType, cacheKey, jsonEncode(items.map((i) => i.toJson()).toList()));
|
||||
}
|
||||
|
||||
/// Persist raw JSON detail data to database (fire-and-forget)
|
||||
void _persistDetailData(String parentType, String cacheKey, String dataJson) {
|
||||
() async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
await DatabaseService.instance.saveDetailTracks(parentType, cacheKey, dataJson);
|
||||
_logger.log('💾 Persisted detail data ($parentType/$cacheKey) to database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to persist detail data: $e');
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
/// Get album tracks with database fallback (memory → DB → null)
|
||||
Future<List<Track>?> getAlbumTracksWithDbFallback(String cacheKey) async {
|
||||
// Check memory cache first
|
||||
final memCached = _albumTracksCache[cacheKey];
|
||||
if (memCached != null) return memCached;
|
||||
|
||||
// Try database
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return null;
|
||||
final dbResult = await DatabaseService.instance.getDetailTracks('album', cacheKey);
|
||||
if (dbResult == null) return null;
|
||||
|
||||
final tracks = (jsonDecode(dbResult.tracksJson) as List)
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Warm memory cache
|
||||
_albumTracksCache[cacheKey] = tracks;
|
||||
_albumTracksCacheTime[cacheKey] = dbResult.lastFetched;
|
||||
_logger.log('📦 Loaded ${tracks.length} album tracks from database for $cacheKey');
|
||||
return tracks;
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load album tracks from database: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist tracks with database fallback (memory → DB → null)
|
||||
Future<List<Track>?> getPlaylistTracksWithDbFallback(String cacheKey) async {
|
||||
// Check memory cache first
|
||||
final memCached = _playlistTracksCache[cacheKey];
|
||||
if (memCached != null) return memCached;
|
||||
|
||||
// Try database
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return null;
|
||||
final dbResult = await DatabaseService.instance.getDetailTracks('playlist', cacheKey);
|
||||
if (dbResult == null) return null;
|
||||
|
||||
final tracks = (jsonDecode(dbResult.tracksJson) as List)
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Warm memory cache
|
||||
_playlistTracksCache[cacheKey] = tracks;
|
||||
_playlistTracksCacheTime[cacheKey] = dbResult.lastFetched;
|
||||
_logger.log('📦 Loaded ${tracks.length} playlist tracks from database for $cacheKey');
|
||||
return tracks;
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load playlist tracks from database: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,23 @@ class DatabaseService {
|
||||
/// Clear all search history
|
||||
Future<void> clearSearchHistory() => db.clearSearchHistory();
|
||||
|
||||
// ============================================
|
||||
// Detail Track Cache Convenience Methods
|
||||
// ============================================
|
||||
|
||||
/// Save detail tracks for an album or playlist
|
||||
Future<void> saveDetailTracks(String parentType, String parentKey, String tracksJson) {
|
||||
return db.saveDetailTracks(parentType, parentKey, tracksJson);
|
||||
}
|
||||
|
||||
/// Get cached detail tracks for an album or playlist
|
||||
Future<DetailTrackCacheData?> getDetailTracks(String parentType, String parentKey) {
|
||||
return db.getDetailTracks(parentType, parentKey);
|
||||
}
|
||||
|
||||
/// Clear all detail track cache
|
||||
Future<void> clearDetailTrackCache() => db.clearDetailTrackCache();
|
||||
|
||||
// ============================================
|
||||
// Cast-to-Sendspin Mapping Methods
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'debug_logger.dart';
|
||||
|
||||
/// Custom cache manager for album images with extended retention
|
||||
class AlbumImageCacheManager extends CacheManager with ImageCacheManager {
|
||||
static const key = 'albumImageCache';
|
||||
|
||||
static final AlbumImageCacheManager _instance = AlbumImageCacheManager._();
|
||||
factory AlbumImageCacheManager() => _instance;
|
||||
|
||||
AlbumImageCacheManager._()
|
||||
: super(Config(
|
||||
key,
|
||||
stalePeriod: const Duration(days: 30),
|
||||
maxNrOfCacheObjects: 2000,
|
||||
));
|
||||
}
|
||||
|
||||
/// Service to prefetch album images in the background after sync
|
||||
class ImagePrefetchService {
|
||||
static final _logger = DebugLogger();
|
||||
bool _cancelled = false;
|
||||
|
||||
/// Cancel any in-progress prefetch
|
||||
void cancel() {
|
||||
_cancelled = true;
|
||||
}
|
||||
|
||||
/// Prefetch images in batches, skipping already-cached URLs
|
||||
Future<void> prefetchImages(List<String> urls) async {
|
||||
_cancelled = false;
|
||||
if (urls.isEmpty) return;
|
||||
|
||||
final cacheManager = AlbumImageCacheManager();
|
||||
int cached = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
|
||||
const batchSize = 10;
|
||||
for (var i = 0; i < urls.length; i += batchSize) {
|
||||
if (_cancelled) {
|
||||
_logger.log('🖼️ Image prefetch cancelled after $cached cached, $skipped skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
final batch = urls.skip(i).take(batchSize);
|
||||
await Future.wait(batch.map((url) async {
|
||||
try {
|
||||
final fileInfo = await cacheManager.getFileFromCache(url);
|
||||
if (fileInfo != null) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
await cacheManager.downloadFile(url);
|
||||
cached++;
|
||||
} catch (_) {
|
||||
failed++;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
_logger.log('🖼️ Image prefetch complete: $cached cached, $skipped already cached, $failed failed (${urls.length} total)');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import '../utils/lru_cache.dart';
|
||||
@@ -259,8 +261,17 @@ class MetadataService {
|
||||
/// Cache for album images - includes null entries for failed lookups (200 entries)
|
||||
static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
|
||||
|
||||
/// Cache for author images (audiobooks) - includes null entries for failed lookups (100 entries)
|
||||
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
|
||||
/// Cache for author images (audiobooks) - includes null entries for failed lookups (500 entries)
|
||||
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 500);
|
||||
|
||||
/// In-flight author image requests for deduplication
|
||||
static final Map<String, Completer<String?>> _authorImageInFlight = {};
|
||||
|
||||
/// SharedPreferences key for persisted author images
|
||||
static const _authorImagePrefsKey = 'author_image_urls';
|
||||
|
||||
/// Whether persisted author images have been loaded into memory cache
|
||||
static bool _authorImageCacheWarmed = false;
|
||||
|
||||
/// Fetches album cover URL from Deezer (free, no API key required)
|
||||
/// Returns the image URL if found, null otherwise
|
||||
@@ -649,33 +660,122 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Fetches author image URL from multiple sources with fuzzy name matching
|
||||
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
|
||||
/// Tries name variations if exact match fails
|
||||
/// Load persisted author image URLs into memory cache on first access
|
||||
static Future<void> _warmAuthorImageCache() async {
|
||||
if (_authorImageCacheWarmed) return;
|
||||
_authorImageCacheWarmed = true;
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_authorImagePrefsKey);
|
||||
if (jsonStr != null) {
|
||||
final map = (json.decode(jsonStr) as Map<String, dynamic>).cast<String, String>();
|
||||
for (final entry in map.entries) {
|
||||
_authorImageCache[entry.key] = entry.value;
|
||||
}
|
||||
_logger.log('📸 Loaded ${map.length} persisted author images');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to load persisted author images: $e', context: 'Metadata');
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist an author image URL to SharedPreferences
|
||||
static Future<void> _persistAuthorImage(String cacheKey, String url) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString(_authorImagePrefsKey);
|
||||
final map = jsonStr != null
|
||||
? (json.decode(jsonStr) as Map<String, dynamic>).cast<String, String>()
|
||||
: <String, String>{};
|
||||
map[cacheKey] = url;
|
||||
// Cap at 500 entries to prevent unbounded growth
|
||||
if (map.length > 500) {
|
||||
final keys = map.keys.toList();
|
||||
for (var i = 0; i < map.length - 500; i++) {
|
||||
map.remove(keys[i]);
|
||||
}
|
||||
}
|
||||
await prefs.setString(_authorImagePrefsKey, json.encode(map));
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to persist author image: $e', context: 'Metadata');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that an image URL actually returns an image
|
||||
static Future<bool> _validateImageUrl(String url) async {
|
||||
try {
|
||||
final response = await http.head(Uri.parse(url)).timeout(const Duration(seconds: 3));
|
||||
return response.statusCode == 200 || response.statusCode == 301 ||
|
||||
response.statusCode == 302 || response.statusCode == 307 || response.statusCode == 308;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Try a single source with all name variations, return first validated result
|
||||
static Future<String?> _trySourceWithVariations(
|
||||
Future<String?> Function(String name) source,
|
||||
List<String> variations, {
|
||||
bool alreadyValidated = false,
|
||||
}) async {
|
||||
for (final name in variations) {
|
||||
final url = await source(name);
|
||||
if (url != null) {
|
||||
if (alreadyValidated || await _validateImageUrl(url)) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Fetches author image URL from multiple sources in parallel
|
||||
/// Sources: 1. Audnexus, 2. Open Library, 3. Wikidata, 4. Wikipedia
|
||||
/// All sources run in parallel; first validated result wins
|
||||
static Future<String?> getAuthorImageUrl(String authorName) async {
|
||||
// Warm cache from persistence on first access
|
||||
await _warmAuthorImageCache();
|
||||
|
||||
// Check cache first
|
||||
final cacheKey = 'authorImage:$authorName';
|
||||
if (_authorImageCache.containsKey(cacheKey)) {
|
||||
return _authorImageCache[cacheKey];
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests for the same author
|
||||
if (_authorImageInFlight.containsKey(cacheKey)) {
|
||||
return _authorImageInFlight[cacheKey]!.future;
|
||||
}
|
||||
|
||||
final completer = Completer<String?>();
|
||||
_authorImageInFlight[cacheKey] = completer;
|
||||
|
||||
try {
|
||||
final result = await _fetchAuthorImage(authorName, cacheKey);
|
||||
completer.complete(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
completer.complete(null);
|
||||
return null;
|
||||
} finally {
|
||||
_authorImageInFlight.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal fetch logic (called once per author, deduplicated)
|
||||
static Future<String?> _fetchAuthorImage(String authorName, String cacheKey) async {
|
||||
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
|
||||
// Try to get image for the first/primary author
|
||||
if (authorName.contains(',')) {
|
||||
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
|
||||
// Skip if this looks like "Lastname, Firstname" format or has suffixes
|
||||
// - Second part is initials (J.R.R., J.K., etc.)
|
||||
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
|
||||
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
|
||||
if (authors.length == 2) {
|
||||
final secondPart = authors[1].toLowerCase();
|
||||
// Check if second part is a suffix or looks like initials
|
||||
if (suffixes.contains(secondPart) ||
|
||||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
|
||||
// This is "Lastname, First" or "Name, Jr." - don't split
|
||||
// "Lastname, First" or "Name, Jr." - don't split
|
||||
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
|
||||
// Both parts have multiple words - likely "Author1, Author2"
|
||||
for (final author in authors) {
|
||||
final result = await getAuthorImageUrl(author);
|
||||
if (result != null) {
|
||||
@@ -683,10 +783,8 @@ class MetadataService {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// Don't cache null here - fall through to try the full name
|
||||
}
|
||||
} else if (authors.length > 2) {
|
||||
// Multiple commas = definitely multiple authors
|
||||
for (final author in authors) {
|
||||
final result = await getAuthorImageUrl(author);
|
||||
if (result != null) {
|
||||
@@ -694,55 +792,28 @@ class MetadataService {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// Don't cache null - fall through to try full name
|
||||
}
|
||||
}
|
||||
|
||||
// Generate name variations for fuzzy matching
|
||||
final variations = _generateNameVariations(authorName);
|
||||
|
||||
// Try each source with all variations before moving to next source
|
||||
// This prioritizes source quality over name variation
|
||||
// Run all sources in parallel - first validated result wins
|
||||
final results = await Future.wait([
|
||||
_trySourceWithVariations(_tryAudnexus, variations),
|
||||
_trySourceWithVariations(_tryOpenLibrary, variations, alreadyValidated: true),
|
||||
_trySourceWithVariations(_tryWikidata, variations.take(2).toList()),
|
||||
_trySourceWithVariations(_tryWikipedia, variations.take(3).toList()),
|
||||
]);
|
||||
|
||||
// 1. Try Audnexus (best for audiobook authors)
|
||||
for (final name in variations) {
|
||||
final result = await _tryAudnexus(name);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
// Pick first non-null result (priority: Audnexus > OpenLibrary > Wikidata > Wikipedia)
|
||||
final result = results[0] ?? results[1] ?? results[2] ?? results[3];
|
||||
|
||||
_authorImageCache[cacheKey] = result;
|
||||
if (result != null) {
|
||||
_persistAuthorImage(cacheKey, result);
|
||||
}
|
||||
|
||||
// 2. Try Open Library
|
||||
for (final name in variations) {
|
||||
final result = await _tryOpenLibrary(name);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try Wikidata (structured data, good for finding exact person)
|
||||
for (final name in variations.take(2)) {
|
||||
final wikidataResult = await _tryWikidata(name);
|
||||
if (wikidataResult != null) {
|
||||
_authorImageCache[cacheKey] = wikidataResult;
|
||||
return wikidataResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try Wikipedia (search-based, good fallback)
|
||||
for (final name in variations.take(3)) {
|
||||
final wikiResult = await _tryWikipedia(name);
|
||||
if (wikiResult != null) {
|
||||
_authorImageCache[cacheKey] = wikiResult;
|
||||
return wikiResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the null result to avoid repeated failed lookups
|
||||
_authorImageCache[cacheKey] = null;
|
||||
return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Cache for lyrics - uses bounded Map with manual size limiting (500 entries max)
|
||||
|
||||
@@ -68,6 +68,14 @@ class MusicAssistantAPI {
|
||||
|
||||
MusicAssistantAPI(this.serverUrl, this.authManager);
|
||||
|
||||
/// Check if a URL string has an explicit port (e.g., ":80", ":8095").
|
||||
/// Dart's Uri.hasPort returns false for default ports (80/443) even when
|
||||
/// explicitly specified, so we check the raw string instead.
|
||||
bool _hasExplicitPort(String url) {
|
||||
final withoutScheme = url.replaceFirst(RegExp(r'^https?://'), '');
|
||||
return RegExp(r'^[^/:]+:\d+').hasMatch(withoutScheme);
|
||||
}
|
||||
|
||||
// Guard to prevent multiple simultaneous connection attempts
|
||||
Completer<void>? _connectionInProgress;
|
||||
bool _isDisposed = false;
|
||||
@@ -145,7 +153,7 @@ class MusicAssistantAPI {
|
||||
queryParameters: {'client_id': clientId},
|
||||
);
|
||||
_logger.log('Using custom WebSocket port from settings: $_cachedCustomPort');
|
||||
} else if (uri.hasPort) {
|
||||
} else if (_hasExplicitPort(serverUrl)) {
|
||||
// Port is already specified in URL, keep it
|
||||
finalUri = Uri(
|
||||
scheme: uri.scheme,
|
||||
@@ -251,7 +259,6 @@ class MusicAssistantAPI {
|
||||
void _handleMessage(dynamic message) {
|
||||
try {
|
||||
final data = jsonDecode(message as String) as Map<String, dynamic>;
|
||||
_logger.log('Received message: ${data.keys}');
|
||||
|
||||
// Check for server info message (first message on connect)
|
||||
if (data.containsKey('server_version')) {
|
||||
@@ -1621,36 +1628,28 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
||||
return await RetryHelper.retryNetwork(
|
||||
operation: () async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/albums/album_tracks',
|
||||
args: {
|
||||
'provider_instance_id_or_domain': provider,
|
||||
'item_id': itemId,
|
||||
},
|
||||
);
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/albums/album_tracks',
|
||||
args: {
|
||||
'provider_instance_id_or_domain': provider,
|
||||
'item_id': itemId,
|
||||
},
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
_logger.log('No result for album tracks');
|
||||
return <Track>[];
|
||||
}
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
_logger.log('No result for album tracks');
|
||||
return <Track>[];
|
||||
}
|
||||
|
||||
|
||||
return items
|
||||
.map((item) => Track.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting album tracks: $e');
|
||||
return <Track>[];
|
||||
}
|
||||
},
|
||||
).catchError((e) {
|
||||
_logger.log('Error getting album tracks after retries: $e');
|
||||
return <Track>[];
|
||||
});
|
||||
return items
|
||||
.map((item) => Track.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting album tracks: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlists
|
||||
@@ -1711,35 +1710,28 @@ class MusicAssistantAPI {
|
||||
|
||||
/// Get playlist tracks
|
||||
Future<List<Track>> getPlaylistTracks(String provider, String itemId) async {
|
||||
return await RetryHelper.retryNetwork(
|
||||
operation: () async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/playlists/playlist_tracks',
|
||||
args: {
|
||||
'provider_instance_id_or_domain': provider,
|
||||
'item_id': itemId,
|
||||
},
|
||||
);
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/playlists/playlist_tracks',
|
||||
args: {
|
||||
'provider_instance_id_or_domain': provider,
|
||||
'item_id': itemId,
|
||||
},
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
_logger.log('No result for playlist tracks');
|
||||
return <Track>[];
|
||||
}
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
_logger.log('No result for playlist tracks');
|
||||
return <Track>[];
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => Track.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting playlist tracks: $e');
|
||||
return <Track>[];
|
||||
}
|
||||
},
|
||||
).catchError((e) {
|
||||
_logger.log('Error getting playlist tracks after retries: $e');
|
||||
return <Track>[];
|
||||
});
|
||||
return items
|
||||
.map((item) => Track.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting playlist tracks: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Favorites
|
||||
@@ -1996,7 +1988,6 @@ class MusicAssistantAPI {
|
||||
currentIndex = queueResult['current_index'] as int?;
|
||||
shuffleEnabled = queueResult['shuffle_enabled'] as bool?;
|
||||
repeatMode = queueResult['repeat_mode'] as String?;
|
||||
_logger.log('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode');
|
||||
|
||||
final currentItemData = queueResult['current_item'] as Map<String, dynamic>?;
|
||||
final currentItemName = currentItemData?['name'] as String?;
|
||||
@@ -2032,7 +2023,7 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
if (items.isEmpty) {
|
||||
_logger.log('⚠️ Queue is empty or all items failed to parse');
|
||||
_logger.debug('⚠️ Queue is empty or all items failed to parse');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2499,7 +2490,7 @@ class MusicAssistantAPI {
|
||||
port: _cachedCustomPort,
|
||||
path: '/flow/$playerId/$streamId.$extension',
|
||||
);
|
||||
} else if (uri.hasPort) {
|
||||
} else if (_hasExplicitPort(serverUrl)) {
|
||||
// Use port from URL
|
||||
finalUri = Uri(
|
||||
scheme: uri.scheme,
|
||||
@@ -3211,7 +3202,7 @@ class MusicAssistantAPI {
|
||||
if (_cachedCustomPort != null) {
|
||||
// Use cached custom port from settings
|
||||
baseUrl = '${uriObj.scheme}://${uriObj.host}:$_cachedCustomPort';
|
||||
} else if (!uriObj.hasPort) {
|
||||
} else if (!_hasExplicitPort(serverUrl)) {
|
||||
if (useSecure) {
|
||||
// For HTTPS, don't add port 443 (it's the default)
|
||||
baseUrl = '${uriObj.scheme}://${uriObj.host}';
|
||||
@@ -3318,7 +3309,7 @@ class MusicAssistantAPI {
|
||||
if (_cachedCustomPort != null) {
|
||||
// Use cached custom port from settings
|
||||
baseUrl = '${uri.scheme}://${uri.host}:$_cachedCustomPort';
|
||||
} else if (!uri.hasPort) {
|
||||
} else if (!_hasExplicitPort(serverUrl)) {
|
||||
if (useSecure) {
|
||||
// For HTTPS, don't add port 443 (it's the default)
|
||||
baseUrl = '${uri.scheme}://${uri.host}';
|
||||
|
||||
@@ -69,6 +69,7 @@ class PcmAudioPlayer {
|
||||
bool _isFeeding = false;
|
||||
bool _isStarted = false;
|
||||
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
|
||||
bool _userPaused = false; // True when pause is user-initiated (not transitional)
|
||||
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
||||
|
||||
// Error callback for operation failures
|
||||
@@ -88,6 +89,9 @@ class PcmAudioPlayer {
|
||||
// Each chunk is ~4KB, so max ~500 chunks
|
||||
static const int _maxBufferChunks = 500;
|
||||
|
||||
// Software volume gain (0.0 = silent, 1.0 = full volume)
|
||||
double _volumeGain = 1.0;
|
||||
|
||||
// Stats
|
||||
int _framesPlayed = 0;
|
||||
int _bytesPlayed = 0;
|
||||
@@ -113,6 +117,11 @@ class PcmAudioPlayer {
|
||||
_state == PcmPlayerState.resuming ||
|
||||
_state == PcmPlayerState.stopping;
|
||||
|
||||
/// Set software volume gain (0.0 = silent, 1.0 = full). Applied to PCM samples at feed time.
|
||||
void setVolumeGain(double gain) {
|
||||
_volumeGain = gain.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Check if feeding should be blocked
|
||||
bool get _shouldBlockFeeding => isTransitioning || _state == PcmPlayerState.paused;
|
||||
|
||||
@@ -227,7 +236,9 @@ class PcmAudioPlayer {
|
||||
if (_shouldBlockFeeding) {
|
||||
// If we're paused and audio is arriving, this is likely a new stream
|
||||
// starting before the stream/start message. Queue for auto-recovery.
|
||||
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
||||
// BUT: don't auto-recover if the user explicitly paused — the arriving
|
||||
// audio is just the tail end of the previous stream.
|
||||
if (_state == PcmPlayerState.paused && !_isAutoRecovering && !_userPaused) {
|
||||
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
||||
_isAutoRecovering = true;
|
||||
_addToBuffer(audioData);
|
||||
@@ -360,7 +371,10 @@ class PcmAudioPlayer {
|
||||
|
||||
// Convert Uint8List (raw bytes) to Int16 samples
|
||||
// Sendspin sends 16-bit little-endian PCM
|
||||
final samples = _bytesToInt16List(chunk);
|
||||
final rawSamples = _bytesToInt16List(chunk);
|
||||
final samples = _volumeGain == 1.0
|
||||
? rawSamples
|
||||
: rawSamples.map((s) => (s * _volumeGain).round().clamp(-32768, 32767)).toList();
|
||||
|
||||
// Check state again before the async feed call
|
||||
if (samples.isNotEmpty && !_shouldBlockFeeding) {
|
||||
@@ -481,6 +495,7 @@ class PcmAudioPlayer {
|
||||
|
||||
if (_state == PcmPlayerState.paused) {
|
||||
// Resume from pause - need to re-initialize since we released on pause
|
||||
_userPaused = false;
|
||||
_logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s');
|
||||
_state = PcmPlayerState.resuming;
|
||||
|
||||
@@ -536,6 +551,7 @@ class PcmAudioPlayer {
|
||||
}
|
||||
|
||||
_logger.log('PcmAudioPlayer: Pause requested');
|
||||
_userPaused = true;
|
||||
|
||||
// Set pausing state FIRST to stop feed loop from starting new feeds
|
||||
_state = PcmPlayerState.pausing;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'debug_logger.dart';
|
||||
|
||||
/// Single source of truth for playback position tracking.
|
||||
///
|
||||
@@ -13,8 +12,6 @@ import 'debug_logger.dart';
|
||||
/// All consumers (UI, notification) should listen to [positionStream]
|
||||
/// instead of calculating position themselves.
|
||||
class PositionTracker {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
|
||||
// Current state
|
||||
String? _playerId;
|
||||
bool _isPlaying = false;
|
||||
@@ -100,7 +97,6 @@ class PositionTracker {
|
||||
Duration? duration,
|
||||
double? serverTimestamp,
|
||||
}) {
|
||||
_logger.log('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId');
|
||||
final bool playerChanged = _playerId != playerId;
|
||||
final bool playStateChanged = _isPlaying != isPlaying;
|
||||
final bool durationChanged = duration != null && _duration != duration;
|
||||
@@ -179,11 +175,8 @@ class PositionTracker {
|
||||
final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying
|
||||
? currentInterpolated // Keep our interpolated position, just refresh the anchor time
|
||||
: anchorPos; // Use server position for other cases
|
||||
_logger.log('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)');
|
||||
_anchorPosition = newAnchorPos;
|
||||
_anchorTime = DateTime.now();
|
||||
} else if (positionDiff > 2) {
|
||||
_logger.log('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)');
|
||||
}
|
||||
|
||||
_isPlaying = isPlaying;
|
||||
@@ -227,7 +220,6 @@ class PositionTracker {
|
||||
|
||||
/// Clear tracker state (e.g., when disconnecting)
|
||||
void clear() {
|
||||
_logger.log('⏱️ PositionTracker.clear() called');
|
||||
_stopInterpolationTimer();
|
||||
_playerId = null;
|
||||
_isPlaying = false;
|
||||
@@ -237,20 +229,9 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
void _startInterpolationTimer() {
|
||||
if (_interpolationTimer != null) {
|
||||
_logger.log('⏱️ PositionTracker: Timer already running, skipping start');
|
||||
return;
|
||||
}
|
||||
if (_interpolationTimer != null) return;
|
||||
|
||||
_logger.log('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)');
|
||||
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) {
|
||||
// Log every 4 ticks (1 second) if no emission happened
|
||||
if (timer.tick % 4 == 0) {
|
||||
final pos = currentPosition;
|
||||
if (pos.inSeconds == _lastEmittedSeconds) {
|
||||
_logger.log('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged');
|
||||
}
|
||||
}
|
||||
_emitPosition();
|
||||
});
|
||||
|
||||
@@ -259,7 +240,6 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
void _stopInterpolationTimer() {
|
||||
_logger.log('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})');
|
||||
_interpolationTimer?.cancel();
|
||||
_interpolationTimer = null;
|
||||
}
|
||||
@@ -271,7 +251,6 @@ class PositionTracker {
|
||||
// Only emit if second changed (reduces unnecessary updates)
|
||||
if (seconds != _lastEmittedSeconds) {
|
||||
_lastEmittedSeconds = seconds;
|
||||
_logger.log('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)');
|
||||
_positionController.add(pos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ class SendspinService {
|
||||
'name': _playerName,
|
||||
'version': 1, // Protocol version (integer)
|
||||
'supported_roles': ['player@v1'],
|
||||
'player_support': {
|
||||
'player@v1_support': {
|
||||
'supported_formats': [
|
||||
{
|
||||
'codec': 'pcm',
|
||||
@@ -321,8 +321,6 @@ class SendspinService {
|
||||
final data = jsonDecode(message) as Map<String, dynamic>;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
_logger.log('Sendspin: Received message type: $type');
|
||||
|
||||
switch (type) {
|
||||
case 'server/hello':
|
||||
// Server acknowledged our client/hello - we're registered!
|
||||
@@ -427,7 +425,25 @@ class SendspinService {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'server/command':
|
||||
final cmdPayload = data['payload'] as Map<String, dynamic>?;
|
||||
final playerCmd = cmdPayload?['player'] as Map<String, dynamic>?;
|
||||
final command = playerCmd?['command'] as String?;
|
||||
if (command == 'volume') {
|
||||
final level = playerCmd?['volume'] as int?;
|
||||
if (level != null) {
|
||||
_volume = level;
|
||||
onVolume?.call(level);
|
||||
}
|
||||
} else if (command == 'mute') {
|
||||
final muted = playerCmd?['mute'] as bool? ?? false;
|
||||
_isMuted = muted;
|
||||
onVolume?.call(muted ? 0 : _volume);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volume':
|
||||
// Legacy protocol - kept for compatibility
|
||||
final level = data['level'] as int?;
|
||||
if (level != null) {
|
||||
_volume = level;
|
||||
@@ -523,7 +539,7 @@ class SendspinService {
|
||||
|
||||
try {
|
||||
final json = jsonEncode(message);
|
||||
_logger.log('Sendspin: Sending message: ${message['type']}');
|
||||
_logger.debug('Sendspin: Sending message: ${message['type']}');
|
||||
_channel!.sink.add(json);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending message: $e');
|
||||
|
||||
@@ -43,6 +43,7 @@ class SyncService with ChangeNotifier {
|
||||
List<Artist> _cachedArtists = [];
|
||||
List<Audiobook> _cachedAudiobooks = [];
|
||||
List<Playlist> _cachedPlaylists = [];
|
||||
List<MediaItem> _cachedPodcasts = [];
|
||||
|
||||
// Source provider tracking for client-side filtering
|
||||
// Maps itemId -> list of provider instance IDs that provided the item
|
||||
@@ -50,7 +51,6 @@ class SyncService with ChangeNotifier {
|
||||
Map<String, List<String>> _artistSourceProviders = {};
|
||||
Map<String, List<String>> _audiobookSourceProviders = {};
|
||||
Map<String, List<String>> _playlistSourceProviders = {};
|
||||
|
||||
// Getters
|
||||
SyncStatus get status => _status;
|
||||
String? get lastError => _lastError;
|
||||
@@ -60,15 +60,16 @@ class SyncService with ChangeNotifier {
|
||||
List<Artist> get cachedArtists => _cachedArtists;
|
||||
List<Audiobook> get cachedAudiobooks => _cachedAudiobooks;
|
||||
List<Playlist> get cachedPlaylists => _cachedPlaylists;
|
||||
List<MediaItem> get cachedPodcasts => _cachedPodcasts;
|
||||
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty ||
|
||||
_cachedPodcasts.isNotEmpty;
|
||||
|
||||
// Source provider getters for client-side filtering
|
||||
Map<String, List<String>> get albumSourceProviders => _albumSourceProviders;
|
||||
Map<String, List<String>> get artistSourceProviders => _artistSourceProviders;
|
||||
Map<String, List<String>> get audiobookSourceProviders => _audiobookSourceProviders;
|
||||
Map<String, List<String>> get playlistSourceProviders => _playlistSourceProviders;
|
||||
|
||||
/// Load library data from database cache (instant)
|
||||
/// Call this on app startup for immediate data
|
||||
/// Also loads source provider info for client-side filtering
|
||||
@@ -145,8 +146,20 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Load podcasts from cache (no source provider tracking - API doesn't support filtering)
|
||||
final podcastData = await _db.getCachedItems('podcast');
|
||||
_cachedPodcasts = [];
|
||||
for (final data in podcastData) {
|
||||
try {
|
||||
_cachedPodcasts.add(MediaItem.fromJson(data));
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse cached podcast: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
|
||||
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
|
||||
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists, '
|
||||
'${_cachedPodcasts.length} podcasts from cache');
|
||||
_logger.log('📦 Source providers: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists tracked');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -180,8 +193,9 @@ class SyncService with ChangeNotifier {
|
||||
final artistsNeedSync = await _db.needsSync('artists', maxAge: const Duration(minutes: 5));
|
||||
final audiobooksNeedSync = await _db.needsSync('audiobooks', maxAge: const Duration(minutes: 5));
|
||||
final playlistsNeedSync = await _db.needsSync('playlists', maxAge: const Duration(minutes: 5));
|
||||
final podcastsNeedSync = await _db.needsSync('podcasts', maxAge: const Duration(minutes: 5));
|
||||
|
||||
if (!albumsNeedSync && !artistsNeedSync && !audiobooksNeedSync && !playlistsNeedSync) {
|
||||
if (!albumsNeedSync && !artistsNeedSync && !audiobooksNeedSync && !playlistsNeedSync && !podcastsNeedSync) {
|
||||
_logger.log('✅ Cache is fresh, skipping sync');
|
||||
return;
|
||||
}
|
||||
@@ -207,6 +221,7 @@ class SyncService with ChangeNotifier {
|
||||
await _db.clearCacheForType('artist');
|
||||
await _db.clearCacheForType('audiobook');
|
||||
await _db.clearCacheForType('playlist');
|
||||
await _db.clearCacheForType('podcast');
|
||||
}
|
||||
|
||||
// Build NEW source tracking maps - don't modify existing until sync is complete
|
||||
@@ -251,6 +266,9 @@ class SyncService with ChangeNotifier {
|
||||
_logger.log(' 📡 Syncing provider: $providerId');
|
||||
|
||||
// Fetch from this specific provider
|
||||
// Note: Tracks are NOT fetched during background sync to avoid
|
||||
// overwhelming provider rate limits (especially Spotify).
|
||||
// Tracks are loaded on-demand when the user visits the tracks tab.
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
|
||||
@@ -263,7 +281,7 @@ class SyncService with ChangeNotifier {
|
||||
final audiobooks = results[2] as List<Audiobook>;
|
||||
final playlists = results[3] as List<Playlist>;
|
||||
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks, ${playlists.length} playlists from $providerId');
|
||||
|
||||
// Add to maps and track source provider in NEW tracking maps
|
||||
for (final album in albums) {
|
||||
@@ -325,20 +343,31 @@ class SyncService with ChangeNotifier {
|
||||
final audiobooks = audiobookMap.values.toList();
|
||||
final playlists = playlistMap.values.toList();
|
||||
|
||||
// Fetch podcasts separately (API doesn't support providerInstanceIds)
|
||||
List<MediaItem> podcasts = [];
|
||||
try {
|
||||
podcasts = await api.getPodcasts(limit: 100);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to fetch podcasts: $e');
|
||||
}
|
||||
|
||||
_logger.log('📥 Total: ${albums.length} albums, ${artists.length} artists, '
|
||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists');
|
||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists, '
|
||||
'${podcasts.length} podcasts');
|
||||
|
||||
// Save to database cache with source provider info (using NEW tracking maps)
|
||||
await _saveAlbumsToCache(albums, newAlbumSourceProviders);
|
||||
await _saveArtistsToCache(artists, newArtistSourceProviders);
|
||||
await _saveAudiobooksToCache(audiobooks, newAudiobookSourceProviders);
|
||||
await _savePlaylistsToCache(playlists, newPlaylistSourceProviders);
|
||||
await _savePodcastsToCache(podcasts);
|
||||
|
||||
// Update sync metadata
|
||||
await _db.updateSyncMetadata('albums', albums.length);
|
||||
await _db.updateSyncMetadata('artists', artists.length);
|
||||
await _db.updateSyncMetadata('audiobooks', audiobooks.length);
|
||||
await _db.updateSyncMetadata('playlists', playlists.length);
|
||||
await _db.updateSyncMetadata('podcasts', podcasts.length);
|
||||
|
||||
// Update in-memory cache
|
||||
if (isPartialSync) {
|
||||
@@ -372,6 +401,9 @@ class SyncService with ChangeNotifier {
|
||||
_cachedAudiobooks = audiobooks;
|
||||
_cachedPlaylists = playlists;
|
||||
}
|
||||
// Podcasts always full replace (no per-provider tracking)
|
||||
_cachedPodcasts = podcasts;
|
||||
|
||||
// Atomically swap in the new source tracking maps
|
||||
// This ensures filtering always sees complete data, never partial
|
||||
_albumSourceProviders = newAlbumSourceProviders;
|
||||
@@ -474,6 +506,24 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save podcasts to database cache (batched, no source provider tracking)
|
||||
Future<void> _savePodcastsToCache(List<MediaItem> podcasts) async {
|
||||
final batchItems = <BatchCacheItem>[];
|
||||
for (final podcast in podcasts) {
|
||||
batchItems.add(BatchCacheItem(
|
||||
itemType: 'podcast',
|
||||
itemId: podcast.itemId,
|
||||
data: jsonEncode(podcast.toJson()),
|
||||
));
|
||||
}
|
||||
try {
|
||||
await _db.batchCacheItems(batchItems);
|
||||
_logger.log('💾 Batch saved ${podcasts.length} podcasts to cache');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to batch cache podcasts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Force a fresh sync (for pull-to-refresh)
|
||||
/// [providerInstanceIds] - optional list of provider IDs to filter by (null = all providers)
|
||||
Future<void> forceSync(MusicAssistantAPI api, {List<String>? providerInstanceIds}) async {
|
||||
@@ -489,6 +539,7 @@ class SyncService with ChangeNotifier {
|
||||
_cachedArtists = [];
|
||||
_cachedAudiobooks = [];
|
||||
_cachedPlaylists = [];
|
||||
_cachedPodcasts = [];
|
||||
_albumSourceProviders = {};
|
||||
_artistSourceProviders = {};
|
||||
_audiobookSourceProviders = {};
|
||||
@@ -574,9 +625,13 @@ class SyncService with ChangeNotifier {
|
||||
/// Get playlists (from cache or empty if not loaded)
|
||||
List<Playlist> getPlaylists() => _cachedPlaylists;
|
||||
|
||||
/// Get podcasts (from cache or empty if not loaded)
|
||||
List<MediaItem> getPodcasts() => _cachedPodcasts;
|
||||
|
||||
/// Check if we have data available (from cache or sync)
|
||||
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty ||
|
||||
_cachedPodcasts.isNotEmpty;
|
||||
|
||||
/// Update cached albums with sorted data from provider
|
||||
/// Used when sort order changes - preserves source provider tracking
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../services/image_prefetch_service.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/album_details_screen.dart';
|
||||
@@ -377,6 +378,7 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
cacheManager: AlbumImageCacheManager(),
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../services/image_prefetch_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
@@ -101,7 +102,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
if (imageUrl != null) {
|
||||
// Use CachedNetworkImageProvider to warm the cache
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
CachedNetworkImageProvider(imageUrl, cacheManager: AlbumImageCacheManager()),
|
||||
context,
|
||||
).catchError((_) {
|
||||
// Silently ignore precache errors
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import '../services/image_prefetch_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
@@ -95,7 +96,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
||||
final imageUrl = maProvider.api?.getImageUrl(audiobook, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
CachedNetworkImageProvider(imageUrl, cacheManager: AlbumImageCacheManager()),
|
||||
context,
|
||||
).catchError((_) {
|
||||
// Silently ignore precache errors
|
||||
@@ -449,6 +450,7 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: AlbumImageCacheManager(),
|
||||
// PERF: Duration.zero for hero-wrapped images
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
|
||||
/// A wrapper around CachedNetworkImage for consistent image caching throughout the app.
|
||||
///
|
||||
@@ -18,6 +19,7 @@ class CachedImage extends StatelessWidget {
|
||||
final int? cacheWidth;
|
||||
final int? cacheHeight;
|
||||
final BorderRadius? borderRadius;
|
||||
final BaseCacheManager? cacheManager;
|
||||
|
||||
const CachedImage({
|
||||
super.key,
|
||||
@@ -30,6 +32,7 @@ class CachedImage extends StatelessWidget {
|
||||
this.cacheWidth,
|
||||
this.cacheHeight,
|
||||
this.borderRadius,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -43,6 +46,7 @@ class CachedImage extends StatelessWidget {
|
||||
fit: fit,
|
||||
memCacheWidth: cacheWidth,
|
||||
memCacheHeight: cacheHeight,
|
||||
cacheManager: cacheManager,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
fadeOutDuration: const Duration(milliseconds: 150),
|
||||
placeholder: (context, url) => placeholder ?? Container(
|
||||
|
||||
@@ -81,6 +81,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
String? _lastImageUrl;
|
||||
int _colorExtractionId = 0;
|
||||
|
||||
// Queue state
|
||||
PlayerQueue? _queue;
|
||||
@@ -657,7 +658,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_logger.log('📊 Subscribing to positionStream for progress bar');
|
||||
_positionSubscription = maProvider.positionTracker.positionStream.listen((position) {
|
||||
if (!mounted) return;
|
||||
_logger.log('📊 Progress received position: ${position.inSeconds}s');
|
||||
_logger.debug('📊 Progress received position: ${position.inSeconds}s');
|
||||
_progressNotifier.value = position.inSeconds;
|
||||
});
|
||||
}
|
||||
@@ -757,13 +758,17 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
Future<void> _extractColors(String imageUrl) async {
|
||||
if (_lastImageUrl == imageUrl) return;
|
||||
_lastImageUrl = imageUrl;
|
||||
final extractionId = ++_colorExtractionId;
|
||||
|
||||
try {
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
);
|
||||
|
||||
if (colorSchemes != null && mounted) {
|
||||
if (!mounted) return;
|
||||
if (extractionId != _colorExtractionId) return;
|
||||
|
||||
if (colorSchemes != null) {
|
||||
setState(() {
|
||||
_lightColorScheme = colorSchemes.$1;
|
||||
_darkColorScheme = colorSchemes.$2;
|
||||
@@ -2056,7 +2061,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
: colorScheme.primaryContainer;
|
||||
// Create a darker shade for the "unplayed" portion of progress bar
|
||||
final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!;
|
||||
final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212);
|
||||
final expandedBg = adaptiveScheme?.surface ?? colorScheme.surface;
|
||||
final expandedPrimary = adaptiveScheme?.primary;
|
||||
// Always update to current value - don't preserve stale adaptive colors
|
||||
_currentExpandedBgColor = expandedBg;
|
||||
|
||||
@@ -638,15 +638,16 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
|
||||
// Global player overlay - renders ON TOP so cards slide behind it
|
||||
// Use Selector instead of Consumer to avoid rebuilds during animation
|
||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack})>(
|
||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack, bool hasCachedPlayers})>(
|
||||
selector: (_, provider) => (
|
||||
isConnected: provider.isConnected,
|
||||
hasPlayer: provider.selectedPlayer != null,
|
||||
hasTrack: provider.currentTrack != null,
|
||||
hasCachedPlayers: provider.hasCachedPlayers,
|
||||
),
|
||||
builder: (context, state, child) {
|
||||
// Only show player if connected and has a selected player
|
||||
if (!state.isConnected || !state.hasPlayer) {
|
||||
// Show player if connected with a player, or if cached players exist (brief disconnect)
|
||||
if ((!state.isConnected && !state.hasCachedPlayers) || !state.hasPlayer) {
|
||||
// Reset expansion state to prevent stale values from causing
|
||||
// invisible/non-interactive nav bar when connection restores.
|
||||
// This fixes a bug where expanded player state persisted after
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart' show GlobalPlayerOverlay, BottomSpacing;
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
|
||||
@@ -402,7 +403,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
|
||||
switch (widget.mediaType) {
|
||||
case ContextMenuMediaType.artist:
|
||||
final artist = widget.item as Artist;
|
||||
await maProvider.api?.playArtistRadio(player.playerId, artist);
|
||||
await maProvider.playArtistRadio(player.playerId, artist);
|
||||
break;
|
||||
case ContextMenuMediaType.track:
|
||||
final track = widget.item as Track;
|
||||
@@ -436,7 +437,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
|
||||
switch (widget.mediaType) {
|
||||
case ContextMenuMediaType.artist:
|
||||
final artist = widget.item as Artist;
|
||||
await maProvider.api?.playArtistRadio(player.playerId, artist);
|
||||
await maProvider.playArtistRadio(player.playerId, artist);
|
||||
break;
|
||||
case ContextMenuMediaType.track:
|
||||
final track = widget.item as Track;
|
||||
@@ -500,39 +501,42 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final viewPadding = MediaQuery.of(context).viewPadding;
|
||||
|
||||
// Account for mini player and bottom nav bar
|
||||
final bottomInset = BottomSpacing.withMiniPlayer + viewPadding.bottom;
|
||||
// Account for mini player and bottom nav bar.
|
||||
// On detail screens the nav bar hides and mini player drops to bottom edge.
|
||||
final isOnDetailScreen = context.read<ThemeProvider>().isOnDetailScreen;
|
||||
final bottomInset = isOnDetailScreen
|
||||
? BottomSpacing.miniPlayerHeight + 22.0 + viewPadding.bottom
|
||||
: BottomSpacing.withMiniPlayer + viewPadding.bottom;
|
||||
|
||||
// Position near the tap, but keep on screen
|
||||
double left = widget.position.dx - menuWidth / 2;
|
||||
double top = widget.position.dy;
|
||||
|
||||
// Keep menu on screen horizontally
|
||||
left = left.clamp(8.0, screenSize.width - menuWidth - 8);
|
||||
|
||||
// Estimate menu height based on content
|
||||
double estimatedHeight;
|
||||
double estimatedHeight = 8.0; // vertical padding (4 top + 4 bottom)
|
||||
if (widget.mediaType == ContextMenuMediaType.podcastEpisode) {
|
||||
// Episode menu: 4 items + divider = ~200px
|
||||
estimatedHeight = 200.0;
|
||||
estimatedHeight += 4 * 46.0; // 4 menu items
|
||||
} else {
|
||||
estimatedHeight = widget.showTopRow ? 280.0 : 180.0;
|
||||
// Add height for sort section (header + items)
|
||||
if (widget.showTopRow) estimatedHeight += 60.0; // icon button row
|
||||
estimatedHeight += 3 * 46.0; // Play on, Start radio, Start radio on
|
||||
if (widget.onToggleSort != null) {
|
||||
// Podcast has 4 sort options, others have 2
|
||||
estimatedHeight += widget.mediaType == ContextMenuMediaType.podcast ? 170.0 : 110.0;
|
||||
estimatedHeight += 1.0 + 32.0; // divider + header
|
||||
final sortCount = widget.mediaType == ContextMenuMediaType.podcast ? 4 : 2;
|
||||
estimatedHeight += sortCount * 42.0; // sort items
|
||||
}
|
||||
// Add height for view section (divider + header + 3 items)
|
||||
if (widget.onCycleView != null) {
|
||||
estimatedHeight += 140.0;
|
||||
estimatedHeight += 1.0 + 32.0; // divider + header
|
||||
estimatedHeight += 3 * 42.0; // view mode items
|
||||
}
|
||||
}
|
||||
|
||||
// If menu would go off bottom (accounting for mini player), position above the tap point
|
||||
if (top + estimatedHeight > screenSize.height - bottomInset) {
|
||||
top = widget.position.dy - estimatedHeight;
|
||||
}
|
||||
top = top.clamp(viewPadding.top + 8, screenSize.height - estimatedHeight - bottomInset);
|
||||
// Keep menu below the tap point but above the mini player
|
||||
final top = widget.position.dy.clamp(
|
||||
viewPadding.top + 8,
|
||||
screenSize.height - estimatedHeight - bottomInset,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
|
||||
@@ -72,6 +72,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
setState(() {
|
||||
_systemVolume = volume;
|
||||
});
|
||||
context.read<MusicAssistantProvider>().updateLocalPlayerVolume((volume * 100).round());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -140,6 +141,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
_systemVolume = newVolume / 100.0;
|
||||
});
|
||||
}
|
||||
maProvider.updateLocalPlayerVolume(newVolume);
|
||||
} catch (e) {
|
||||
_logger.log('Volume: Error setting system volume - $e');
|
||||
}
|
||||
@@ -163,6 +165,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
|
||||
if (_isLocalPlayer) {
|
||||
FlutterVolumeController.setVolume(volume);
|
||||
maProvider.updateLocalPlayerVolume(volumeLevel);
|
||||
} else {
|
||||
maProvider.setVolume(playerId, volumeLevel);
|
||||
}
|
||||
@@ -171,8 +174,9 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use select to only rebuild when selectedPlayer changes (not on every provider update)
|
||||
// Use select to only rebuild when selectedPlayer or localPlayerVolume changes
|
||||
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
|
||||
final maLocalVolume = context.select<MusicAssistantProvider, int>((p) => p.localPlayerVolume);
|
||||
|
||||
if (player == null) {
|
||||
return const SizedBox.shrink();
|
||||
@@ -182,9 +186,9 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
|
||||
// Use pending volume during drag or button tap to prevent stale state issues
|
||||
final currentVolume = (_isDragging || _showButtonIndicator)
|
||||
? (_pendingVolume ?? (_isLocalPlayer ? (_systemVolume ?? 0.5) : player.volume.toDouble() / 100.0))
|
||||
? (_pendingVolume ?? (_isLocalPlayer ? maLocalVolume / 100.0 : player.volume.toDouble() / 100.0))
|
||||
: _isLocalPlayer
|
||||
? (_systemVolume ?? 0.5)
|
||||
? maLocalVolume / 100.0
|
||||
: player.volume.toDouble() / 100.0;
|
||||
|
||||
// Use accent color if provided, otherwise fall back to theme primary
|
||||
@@ -234,6 +238,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
children: [
|
||||
// The slider with gesture detection for precision mode
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (details) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
@@ -298,6 +303,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
if (_isLocalPlayer) {
|
||||
FlutterVolumeController.setVolume(finalVolume);
|
||||
_systemVolume = finalVolume;
|
||||
context.read<MusicAssistantProvider>().updateLocalPlayerVolume(volumeLevel);
|
||||
} else {
|
||||
context.read<MusicAssistantProvider>().setVolume(player.playerId, volumeLevel);
|
||||
}
|
||||
|
||||
+2
-2
@@ -351,7 +351,7 @@ packages:
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||
@@ -854,7 +854,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 3.0.3+55
|
||||
version: 3.0.4+56
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -27,6 +27,7 @@ dependencies:
|
||||
web_socket_channel: ^2.4.0
|
||||
http: ^1.1.0
|
||||
cached_network_image: ^3.3.1
|
||||
flutter_cache_manager: ^3.3.1
|
||||
|
||||
# Storage
|
||||
shared_preferences: ^2.2.2
|
||||
@@ -49,6 +50,7 @@ dependencies:
|
||||
audio_service: ^0.18.12
|
||||
audio_session: ^0.1.18
|
||||
flutter_volume_controller: ^1.3.2
|
||||
rxdart: ^0.28.0
|
||||
|
||||
# Raw PCM Audio Playback (for Sendspin streaming)
|
||||
flutter_pcm_sound: ^3.3.3
|
||||
|
||||
Reference in New Issue
Block a user