Compare commits

..
21 Commits
Author SHA1 Message Date
Chris 44f35d1a26 WIP on feature/remote-bridge: 8cf35cf Add robust reconnection for remote mode 2026-01-23 22:16:58 +00:00
Chris 6918569a82 index on feature/remote-bridge: 8cf35cf Add robust reconnection for remote mode 2026-01-23 22:16:58 +00:00
ChrisandClaude Opus 4.5 8cf35cfe78 Add robust reconnection for remote mode
Bridge improvements:
- Increase max reconnect attempts to 10 with exponential backoff (2s-60s)
- Auto-recover from failed state when app tries to connect
- Health check triggers retry every 60s when in failed state
- Track last reconnect attempt time for smarter retries

App improvements:
- Retry 5 times with exponential backoff on resume (remote mode)
- Add 30s periodic reconnect timer when disconnected in remote mode
- Timer auto-stops on successful connection
- Multiple retry layers ensure eventual recovery

This fixes the issue where network drops would leave the app
permanently disconnected even after network recovery.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:40:53 +00:00
ChrisandClaude Opus 4.5 42bc1ed16c Fix remote connection stability issues
- Add 30s server hello timeout to prevent indefinite hangs
- Queue ICE candidates until remote description is set
- Fix stuck "connecting" state blocking future connections
- Properly notify clients when WebRTC fails with error codes
- Close WS clients immediately on WebRTC failure (not cache stale hello)
- Reject new connections during reconnection or failed state
- Add _resetConnection() for clean retry scenarios

WebSocket close codes:
- 4000: WebRTC not healthy
- 4001: Connection lost, reconnecting
- 4002: Reconnection in progress
- 4003: Connection failed
- 4004: Bridge terminated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:54:38 +00:00
ChrisandClaude Opus 4.5 d908cf93a2 Remove HA add-on note - too busy
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:03:43 +00:00
ChrisandClaude Opus 4.5 e21e1eb4fd Update remote ID hint format and add HA add-on note
- Change placeholder to realistic format: ABCD1234-EFGH5-6IJKL-MNOP7890
- Add note: "For Home Assistant add-on users only"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:43:07 +00:00
ChrisandClaude Opus 4.5 9aa81ef09f Make QR button same height as remote ID field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:33:55 +00:00
ChrisandClaude Opus 4.5 8d2adb6fc5 Style QR button to match remote ID field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:28:13 +00:00
ChrisandClaude Opus 4.5 99ee131712 Move QR scanner to separate button beside remote ID field
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:15:27 +00:00
ChrisandClaude Opus 4.5 f51ea9cb22 Add QR code scanner for Remote Access ID
- Add QR scanner screen with camera preview and scan overlay
- Wire up QR button to remote ID field on login screen
- Add mobile_scanner dependency
- Add localization strings for scanner UI
- Handle various QR code formats:
  - Raw remote ID
  - ma://remote/<id> URL scheme
  - music-assistant.io URLs with cloudInstanceId param

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:04:42 +00:00
ChrisandClaude Opus 4.5 9773f28cf7 Fix ICE server URL parsing for Nabu Casa users
The signaling server returns URLs as an array for HA Cloud subscribers
but as a string for others. Handle both formats in IceServer.fromJson.

Fixes #31

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:51:32 +00:00
ChrisandClaude Opus 4.5 2856ca1e8b Implement remote connection audit fixes
- WebRTCConnection: Complete pending requests with error on disconnect/peer disconnect
- ErrorHandler: Add WebRTC-specific error types (remote, dataChannel, signaling)
- RemoteBridge: Add RemoteBridgeErrorType, RemoteBridgeState, ConnectionMetrics
  - State and error streams for UI integration
  - Track total reconnection count
- MusicAssistantAPI: Age-based cleanup for pending requests
  - New _PendingRequest class with timestamp
  - Auto-cleanup stale requests older than 2 minutes
- SendspinService: Exponential backoff for reconnection (3s, 6s, 12s... up to 60s)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:42:43 +00:00
ChrisandClaude Opus 4.5 c832502c49 Add default MA port 8095 for local IP addresses
When users enter a local IP address without specifying a port,
auth detection now defaults to port 8095 (MA's default port).
This fixes connection issues for HAOS add-on users who previously
had to manually specify :8095.

Behavior:
- 192.168.4.178 → http://192.168.4.178:8095
- 192.168.4.178:8095 → http://192.168.4.178:8095 (unchanged)
- 192.168.4.178:80 → http://192.168.4.178:80 (explicit port preserved)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:29:48 +00:00
ChrisandClaude Opus 4.5 675779c108 Add remote auto-reconnect on app restart
- Save remote access ID when connecting via remote mode
- Clear remote access ID when connecting via local/direct mode
- On startup, detect if saved URL is a localhost bridge URL
- Re-establish bridge automatically using saved credentials
- Fixes app unable to connect after restart in remote mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:33:00 +00:00
ChrisandClaude Opus 4.5 54a9e6e2b4 Add WebRTC auto-reconnection and increase ping frequency
- Increase signaling ping frequency from 30s to 15s
- Add automatic WebRTC reconnection when connection drops
- Exponential backoff for reconnect attempts (up to 5 tries)
- Preserve WS clients during reconnection for seamless recovery

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:24:53 +00:00
ChrisandClaude Opus 4.5 870acabad1 Add Sendspin audio tunneling through WebRTC bridge for remote local player
- Add second WebRTC data channel for Sendspin audio streaming
- Extend RemoteBridge to handle /sendspin path separately from API
- Route Sendspin text and binary messages through dedicated channel
- Update provider to connect Sendspin via bridge in remote mode
- Fix authentication flow (save credentials before connecting)
- Add sequential sync requests for remote mode stability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:08:17 +00:00
ChrisandClaude Opus 4.5 d3a5542c68 Remove init.gradle from CI - conflicts with Gradle settings mode
GitHub Actions doesn't need Maven mirrors workaround.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:09:10 +00:00
ChrisandClaude Opus 4.5 b93918219f Update CI to Flutter 3.38.0 for intl compatibility
Flutter 3.24 pins intl to 0.19.0 via flutter_localizations,
but project requires intl ^0.20.0. Flutter 3.38 resolves this.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:04:07 +00:00
ChrisandClaude Opus 4.5 a23f9ff05f Add remote access login UI and fix album art for remote mode
- Add Server URL / Remote Access ID toggle to login screen
- Add RemoteBridge connection flow for HA add-on users
- Add isRemoteMode flag to API to detect WebRTC tunnel connections
- Fix album art in remote mode by using external CDN URLs directly
  (Spotify, Tidal, Deezer, Apple Music, Last.fm, MusicBrainz)
- Add debug copy logs button to home screen
- Add localization strings for remote access UI

Note: Local/OpenSubsonic content won't have album art in remote mode
since HTTP image requests can't go through the WebRTC tunnel.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 14:58:13 +00:00
ChrisandClaude Opus 4.5 1921a20884 Add remote bridge infrastructure and fix Flutter 3.38 compatibility issues
Remote Bridge:
- Add WebRTC-based remote connection infrastructure
- Add signaling client for MA remote access protocol
- Add RemoteBridge local WebSocket server for transparent tunneling
- Add RemoteConnectionManager for high-level remote API

Flutter 3.38 Fixes:
- Replace Material 3 Slider with custom implementation (fixes Overlay error)
- Custom progress bar and volume slider using Stack/Container
- Add seek debouncing to prevent command timeout on rapid seeks
- Fix position reporting in Sendspin client/state payload

Other Fixes:
- Fix tab swiping scrolling multiple tabs (use default PageScrollPhysics)
- Fix copy logs to use filtered entries instead of all logs
- Revert share logs to simple implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 13:40:51 +00:00
ChrisandClaude Opus 4.5 516faf6f6a chore: Update Gradle, Kotlin, and AGP for Flutter 3.38 compatibility
- Gradle: 8.0 → 8.9
- Android Gradle Plugin: 8.1.0 → 8.7.0
- Kotlin: 1.9.10 → 2.1.0
- intl: 0.19.0 → 0.20.0
- Fix CardTheme → CardThemeData (Flutter API change)

Required for Flutter 3.38.7 compatibility. The older versions cause
build failures with the current Flutter SDK.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 10:21:32 +00:00
146 changed files with 10065 additions and 18318 deletions
-3
View File
@@ -1,3 +0,0 @@
# These are the funding models supported by Ensemble
github: CollotsSpot
custom: https://ko-fi.com/collotsspot
-388
View File
@@ -1,388 +0,0 @@
# Android Auto Implementation Plan for Ensemble
## 1. Overview
This document describes adding Android Auto support to Ensemble. When a user connects their Android phone to a car head unit, Android Auto will browse and play music through Ensemble without touching the phone.
### What we're building
- A browse hierarchy (Favorites, Playlists, Albums, Radio) accessible from the car screen
- Playback from Android Auto routes through the builtin (local) player, which streams through MA as normal
- Voice search support
### Why builtin player only
Android Auto requires local audio output. The builtin player plays audio through `just_audio` on the device, which the car receives over Bluetooth. Remote MA players are irrelevant in a car context.
---
## 2. Architecture
### Provider reference
`MassivAudioHandler` has no reference to `MusicAssistantProvider` today. The handler is created before the provider (in `main()` before `runApp`). Solution: add a `setProvider()` method and call it from `_MusicAssistantAppState.initState()`.
```
main.dart
audioHandler = await AudioService.init(...) // global, created first
runApp(MusicAssistantApp())
_MusicAssistantAppState.initState()
_musicProvider = MusicAssistantProvider()
audioHandler.setProvider(_musicProvider) // NEW
```
### Browse → play call flow
```
Android Auto MassivAudioHandler MusicAssistantProvider
| | |
getChildren('root') -----> | returns category items |
getChildren('cat|playlists')-> | SyncService.cachedPlaylists |
getChildren('playlist|lib|5')-> | provider.getPlaylistTracksWithCache()
| populates _trackQueueCache |
playFromMediaId('track|lib|7|plist|lib|5') -> |
| lookup _trackQueueCache |
| provider.playTracks(builtinId, tracks, startIndex: i)
```
---
## 3. Media ID Scheme
Separator: `|` (pipe). Provider names use underscores/colons, never pipes — safe.
| ID | Meaning |
|----|---------|
| `root` | Root (browsable) |
| `recent` | Currently playing track (playable) |
| `cat\|favorites` | Favorites category (browsable) |
| `cat\|playlists` | Playlists category (browsable) |
| `cat\|albums` | Albums category (browsable) |
| `cat\|radio` | Radio stations category (browsable) |
| `playlist\|{provider}\|{itemId}` | A playlist (browsable) |
| `album\|{provider}\|{itemId}` | An album (browsable) |
| `track\|{tProvider}\|{tItemId}\|{ctxType}\|{ctxProvider}\|{ctxId}` | A track in context (playable) |
| `radio\|{provider}\|{itemId}` | A radio station (playable) |
The `ctxType|ctxProvider|ctxId` suffix in track IDs encodes the parent container. Context types: `plist`, `album`, `favs`, `search`.
**Track queue cache key** = `{ctxType}|{ctxProvider}|{ctxId}` (e.g. `plist|spotify|abc`). Stored when building children, looked up in `playFromMediaId`.
---
## 4. Browse Hierarchy
```
root
├── recent (playable, shown if something is/was playing)
├── cat|favorites (browsable → favorite tracks, all playable)
├── cat|playlists (browsable → list of playlists, each browsable)
│ └── playlist|{provider}|{id} → tracks (playable)
├── cat|albums (browsable → list of albums, each browsable)
│ └── album|{provider}|{id} → tracks (playable)
└── cat|radio (browsable → radio stations, each playable)
```
Artists are **not included**. Navigating Artist → Albums → Album → Tracks is four taps — too many while driving. Can be added later.
---
## 5. Track Queue Cache
Tapping a track in Android Auto should play the full album/playlist from that track, not just the single track. The handler caches track lists when building children:
```dart
// Key: ctxKey e.g. "plist|spotify|abc" or "album|library|42" or "favs||"
// Value: ordered list of tracks in that container
final Map<String, List<ma.Track>> _trackQueueCache = {};
```
When `getChildren('playlist|spotify|abc')` is called:
1. Fetch tracks via `provider.getPlaylistTracksWithCache('spotify', 'abc')`
2. Store `_trackQueueCache['plist|spotify|abc'] = tracks`
3. Return each track as a playable `MediaItem` with id `track|{t.provider}|{t.itemId}|plist|spotify|abc`
When `playFromMediaId('track|lib|7|plist|spotify|abc')` is called:
1. Parse ctxKey = `plist|spotify|abc`
2. Look up `_trackQueueCache['plist|spotify|abc']`
3. Find track index where `itemId == '7'`
4. Call `provider.playTracks(builtinId, tracks, startIndex: index)`
---
## 6. Key Code for `massiv_audio_handler.dart`
### New imports
```dart
import 'package:rxdart/rxdart.dart';
import '../../providers/music_assistant_provider.dart';
import '../settings_service.dart';
import '../sync_service.dart';
import '../../models/media_item.dart' as ma;
```
Note: `audio_service` exports its own `MediaItem`. Use `ma.MediaItem`, `ma.Track`, `ma.Album`, `ma.Playlist` for app models. The bare `MediaItem` refers to the audio_service type throughout new methods.
### New fields
```dart
MusicAssistantProvider? _provider;
final Map<String, ma.Track> _trackQueueCache = {}; // ctxKey -> tracks (see section 5)
final Map<String, BehaviorSubject<Map<String, dynamic>>> _childrenSubjects = {};
void setProvider(MusicAssistantProvider provider) {
_provider = provider;
}
```
### `getChildren`
```dart
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
final provider = _provider;
if (provider == null) return [];
switch (parentMediaId) {
case AudioService.browsableRootId:
return _buildRoot(provider);
case 'cat|favorites':
return _buildFavorites(provider);
case 'cat|playlists':
return _buildPlaylistList(provider);
case 'cat|albums':
return _buildAlbumList(provider);
case 'cat|radio':
return _buildRadioList(provider);
default:
if (parentMediaId.startsWith('playlist|')) {
final parts = parentMediaId.split('|');
if (parts.length >= 3) return _buildPlaylistTracks(provider, parts[1], parts[2]);
}
if (parentMediaId.startsWith('album|')) {
final parts = parentMediaId.split('|');
if (parts.length >= 3) return _buildAlbumTracks(provider, parts[1], parts[2]);
}
return [];
}
}
```
### `playFromMediaId`
```dart
@override
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) async {
final provider = _provider;
if (provider == null) return;
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
_logger.log('AndroidAuto: playFromMediaId $mediaId');
try {
if (mediaId == 'recent') {
await provider.playPauseSelectedPlayer();
return;
}
if (mediaId.startsWith('radio|')) {
final parts = mediaId.split('|'); // ['radio', provider, itemId]
if (parts.length < 3) return;
final station = provider.radioStations.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => throw Exception('Station not found: $mediaId'),
);
await provider.playRadio(playerId, station);
return;
}
if (mediaId.startsWith('track|')) {
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
final parts = mediaId.split('|');
if (parts.length < 6) return;
final tProvider = parts[1];
final tItemId = parts[2];
final ctxKey = parts.sublist(3).join('|');
final trackList = _trackQueueCache[ctxKey];
if (trackList == null || trackList.isEmpty) {
// Fallback: play single track (minimal Track object)
await provider.playTrack(playerId, ma.Track(itemId: tItemId, provider: tProvider, name: ''));
return;
}
final index = trackList.indexWhere((t) => t.provider == tProvider && t.itemId == tItemId);
await provider.playTracks(playerId, trackList, startIndex: index < 0 ? 0 : index);
return;
}
} catch (e) {
_logger.log('AndroidAuto: playFromMediaId error: $e');
}
}
```
### `search`
```dart
@override
Future<List<MediaItem>> search(String query, [Map<String, dynamic>? extras]) async {
final provider = _provider;
if (provider == null || query.trim().isEmpty) return [];
try {
final results = await provider.searchWithCache(query);
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
const ctxKey = 'search||';
_trackQueueCache[ctxKey] = tracks;
return tracks.map((t) => MediaItem(
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
title: t.name,
artist: t.artistsString,
album: t.album?.name,
duration: t.duration,
artUri: _artUri(provider, t),
playable: true,
)).toList();
} catch (e) {
_logger.log('AndroidAuto: search error: $e');
return [];
}
}
```
### `subscribeToChildren`
```dart
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
_childrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
return _childrenSubjects[parentMediaId]!.stream;
}
```
### `_artUri` helper
```dart
Uri? _artUri(MusicAssistantProvider provider, ma.MediaItem item) {
final url = provider.getImageUrl(item, size: 256);
return url != null ? Uri.tryParse(url) : null;
}
```
---
## 7. Files to Change
### `pubspec.yaml`
Add `rxdart: ^0.28.0` as direct dependency. The `^0.28.0` constraint matches the already-resolved transitive version so no upgrade occurs.
### `android/app/build.gradle`
Change:
```groovy
minSdkVersion flutter.minSdkVersion
```
To:
```groovy
minSdkVersion 23
```
Android Auto requires API 23+. `flutter.minSdkVersion` resolves to 21 — too low.
### `android/app/src/main/AndroidManifest.xml`
Add inside `<application>` tag:
```xml
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
```
The `MediaBrowserService` intent filter on `AudioService` is already present — no changes needed there.
### `android/app/src/main/res/xml/automotive_app_desc.xml` (NEW)
```xml
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
```
### `lib/main.dart`
In `_MusicAssistantAppState.initState()`, after creating `_musicProvider`:
```dart
audioHandler.setProvider(_musicProvider);
```
### `lib/services/audio/massiv_audio_handler.dart`
Add ~200 lines. New imports, fields, `setProvider()`, `getChildren()`, `playFromMediaId()`, `search()`, `subscribeToChildren()`, and private builder helpers. All existing code is untouched.
---
## 8. Testing with DHU (No Car Required)
### Install DHU
In Android Studio: SDK Manager → SDK Tools → Android Auto Desktop Head Unit Emulator.
Or via command line:
```bash
sdkmanager "extras;google;auto"
```
### Enable Android Auto developer mode on phone
1. Settings → Apps → Android Auto → tap version number 10 times
2. In Android Auto developer settings: enable "Unknown sources"
### Run the test session
```bash
# Terminal 1: forward the port
adb forward tcp:5277 tcp:5277
# Terminal 2: start the DHU
$ANDROID_SDK_ROOT/extras/google/auto/desktop-head-unit
```
In Android Auto developer settings on phone: tap "Start head unit server".
### Build and install
```bash
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
```
Launch Ensemble on phone first (so audio service starts). Then tap the media icon in DHU — Ensemble should appear.
### Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| App not visible in DHU | Missing `automotive_app_desc.xml` or wrong meta-data name | Check manifest and XML |
| App visible, browse is empty | `setProvider` not yet called | Check `initState` wiring |
| Build fails | `minSdkVersion` too low | Check `build.gradle` |
| Track plays but wrong song | `_trackQueueCache` miss | Ensure `getChildren` ran before `playFromMediaId` |
---
## 9. Implementation Order
Do these steps in order to test incrementally:
1. Android config files (`build.gradle`, `AndroidManifest.xml`, `automotive_app_desc.xml`) — verify app appears in DHU before writing Dart
2. `pubspec.yaml` + `flutter pub get`
3. `massiv_audio_handler.dart`: add `setProvider`, fields, `subscribeToChildren` — minimum to not crash
4. `main.dart`: wire `setProvider`
5. `massiv_audio_handler.dart`: `getChildren` with hardcoded root categories — verify tree appears in DHU
6. `massiv_audio_handler.dart`: add all `_build*` helpers one by one, test each
7. `massiv_audio_handler.dart`: add `playFromMediaId` — test track playback
8. `massiv_audio_handler.dart`: add `search` — test voice search
---
## 10. Not In Scope
- **Artists browse** — too many taps for driving; albums alone suffice
- **Audiobooks/podcasts** — different UX needs; add later
- **Queue management from Android Auto** — MA queue model is complex
- **Push updates** — `notifyChildrenChanged` stubbed but not wired to sync events
- **Android Automotive OS** — different platform, requires a separate flavor
+391
View File
@@ -0,0 +1,391 @@
# Ensemble Codebase Intensive Audit Prompt
## Overview
You are tasked with conducting an intensive, comprehensive audit of the **Ensemble** Flutter application - a Music Assistant client for Android with multi-device synchronization, local playback, and Sendspin raw audio streaming capabilities.
**Codebase Statistics:**
- 90 Dart files, ~40,583 lines of code
- 18 screens, 27 services, 6 providers
- Architecture: Provider pattern with ChangeNotifier
- Audio: just_audio + flutter_pcm_sound + audio_service
- Networking: WebSocket (JSON-RPC) + HTTP + Sendspin binary protocol
---
## Audit Scope
Perform a thorough audit covering ALL of the following areas. For each issue found, provide:
1. **File path and line number(s)**
2. **Severity**: CRITICAL / HIGH / MEDIUM / LOW
3. **Description** of the issue
4. **Impact** if left unfixed
5. **Recommended fix** with code example where applicable
---
## SECTION 1: SECURITY AUDIT
### 1.1 Credential Storage (CRITICAL PRIORITY)
Audit `/lib/services/settings_service.dart` for:
- [ ] Plaintext password storage in SharedPreferences (lines 209-222)
- [ ] Auth tokens stored without encryption
- [ ] Serialized credentials in JSON format (lines 169-190)
**Expected Finding:** Credentials stored insecurely. Recommend flutter_secure_storage.
### 1.2 Certificate Validation
Audit `/lib/services/auth/auth_manager.dart` for:
- [ ] HTTPS redirect handling for local IPs
- [ ] Certificate validation disabled scenarios
- [ ] MITM vulnerability exposure
### 1.3 Authentication Strategies
Audit all files in `/lib/services/auth/`:
- [ ] `basic_auth_strategy.dart` - Base64 encoding (not encryption)
- [ ] `authelia_strategy.dart` - Session cookie handling, timing attack (line 159)
- [ ] `ma_auth_strategy.dart` - Token refresh mechanism
- [ ] `no_auth_strategy.dart` - Public server exposure
### 1.4 Input Validation
Audit `/lib/services/music_assistant_api.dart`:
- [ ] URI construction without validation (lines 1346-1360)
- [ ] Provider/itemId injection risks
- [ ] WebSocket message validation
### 1.5 Information Disclosure
- [ ] Debug logs exposing sensitive data (check all `_logger.log()` calls)
- [ ] Server version exposure via `server_info`
- [ ] Error messages leaking technical details
---
## SECTION 2: MEMORY MANAGEMENT AUDIT
### 2.1 Static Memory Leaks
Audit `/lib/models/player.dart`:
- [ ] `_playerCreationTimes` static map (lines 96-144) - grows unbounded
- [ ] Cleanup only removes 50 of 100+ entries
### 2.2 Unbounded Caches
Audit `/lib/services/cache_service.dart`:
- [ ] `_albumTracksCache` - no size limit
- [ ] `_artistAlbumsCache` - no TTL expiration
- [ ] `_searchCache` - never evicted
- [ ] `_playerTrackCache` - only cleared on disconnect
### 2.3 Stream/Timer Cleanup
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] `_playerStateTimer` disposal (line 4435-4445)
- [ ] `_notificationPositionTimer` multiple creation paths (lines 2925-2940)
- [ ] `_localPlayerEventSubscription` cleanup
- [ ] `_playerUpdatedEventSubscription` cleanup
- [ ] `_playerAddedEventSubscription` cleanup
Audit `/lib/services/hardware_volume_service.dart`:
- [ ] `_volumeUpController` never closed (lines 74-85)
- [ ] `_volumeDownController` never closed
### 2.4 Stream Subscription Leaks
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] `_api!.connectionState.listen()` - subscription not stored
- [ ] Old subscriptions not cancelled before new API instance
---
## SECTION 3: RACE CONDITION AUDIT
### 3.1 Player State Dual Updates
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] Timer polling (every 5s) vs WebSocket events racing
- [ ] `_updatePlayerState()` called from both paths
- [ ] No mutex/lock protection
### 3.2 Async Method Reentrancy
Audit `selectPlayer()` method:
- [ ] `async` method without guard against concurrent calls
- [ ] `_selectedPlayer` modified before and after `await`
- [ ] State could change during await
### 3.3 Fire-and-Forget Operations
Search for `unawaited(` and `() async {` patterns:
- [ ] `_persistPlaybackState()` - database save not awaited
- [ ] Pause operations fire-and-forget (lines 3876-3894)
- [ ] Preload operations (line 2707)
### 3.4 PCM Player State Machine
Audit `/lib/services/pcm_audio_player.dart`:
- [ ] 9-state machine with complex transitions (lines 32-37)
- [ ] Auto-recovery race condition (lines 215-229)
- [ ] Feed loop during pause transition (lines 174-179)
---
## SECTION 4: ERROR HANDLING AUDIT
### 4.1 Silent Exception Handlers (CRITICAL)
Find all instances of:
```dart
} catch (e) {}
} catch (_) {}
```
Known locations:
- [ ] `music_assistant_provider.dart` (multiple)
- [ ] `player_provider.dart` (3 instances)
- [ ] `recently_played_service.dart`
### 4.2 Missing Error Recovery
- [ ] No exponential backoff on connection retry
- [ ] No retry logic for failed API calls
- [ ] WebSocket timeout handling (30 seconds - too long?)
### 4.3 Null Safety Violations
Find all `!` (null assertion) operators:
- [ ] `search_screen.dart` (20+ instances)
- [ ] `models/media_item.dart`
- [ ] `models/player.dart`
- [ ] `item.mediaItem! as Artist` patterns
---
## SECTION 5: ARCHITECTURAL ISSUES
### 5.1 God Class
Audit `/lib/providers/music_assistant_provider.dart`:
- [ ] 4,469 lines - violates Single Responsibility
- [ ] 386 conditional branches
- [ ] Mixing: connection, players, library, sync, audio, position tracking
**Recommend splitting into:**
- ConnectionProvider (~500 LOC)
- PlayerManager (~800 LOC)
- LibraryProvider (~1000 LOC)
- LocalPlaybackManager (~600 LOC)
### 5.2 Oversized Widget Files
- [ ] `expandable_player.dart` - 2,485 lines (should be <500)
- [ ] `global_player_overlay.dart` - 737 lines
- [ ] `new_library_screen.dart` - 2,521 lines
### 5.3 Global Key Anti-Pattern
Audit `/lib/widgets/global_player_overlay.dart`:
- [ ] `globalPlayerKey` for state access
- [ ] `_overlayStateKey` for overlay state
- [ ] Should use Provider/context instead
### 5.4 Primitive Obsession
- [ ] View modes as strings ('grid2', 'grid3', 'list')
- [ ] Media types as strings ('track', 'album', 'artist')
- [ ] Should be strongly-typed enums
---
## SECTION 6: CODE QUALITY ISSUES
### 6.1 Testing (CRITICAL)
- [ ] **ZERO test files exist** - confirm this
- [ ] No unit tests, widget tests, or integration tests
- [ ] flutter_test dependency exists but unused
### 6.2 Hardcoded Values
Find and document all:
- [ ] Color hex codes (50+ instances of `Color(0xFF...`)
- [ ] Duration values not using `Timings` constants
- [ ] Magic numbers (16, 4, 5, 100, 50)
- [ ] String literals for media types
### 6.3 Mixed Async Patterns
- [ ] `.then()` chains alongside async/await
- [ ] `.whenComplete()` usage
- [ ] Consolidate to async/await
### 6.4 Dead Code
- [ ] `@Deprecated` methods still implemented
- [ ] `animation_debugger.dart` - dev-only?
- [ ] Unused imports
### 6.5 TODO Comments
Document all actionable TODOs:
- [ ] `queue_screen.dart:214` - queue item removal
- [ ] `audiobook_detail_screen.dart:261,312` - progress sync
- [ ] `music_assistant_provider.dart:793,799` - playlist modification
---
## SECTION 7: UI/UX AUDIT
### 7.1 Accessibility (CRITICAL)
Audit for missing:
- [ ] `Semantics` widgets on interactive elements
- [ ] `Tooltip` on icon buttons
- [ ] Screen reader support
- [ ] Touch target sizes (minimum 48dp)
- [ ] Color contrast validation (WCAG AA)
### 7.2 Performance Issues
Audit `/lib/widgets/expandable_player.dart`:
- [ ] No `RepaintBoundary` wrappers
- [ ] 55+ `setState()` calls
- [ ] 3 AnimationControllers with multiple listeners
- [ ] Title height cache invalidation (lines 118+)
### 7.3 Double Opacity Bug
Audit `/lib/widgets/player/player_controls.dart`:
```dart
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
```
- [ ] Double `.withOpacity()` application
### 7.4 Missing Features
- [ ] No landscape orientation support
- [ ] No tablet layout optimization
- [ ] No loading skeleton animations
- [ ] No haptic feedback (except one location)
---
## SECTION 8: AUDIO PLAYBACK AUDIT
### 8.1 Position Interpolation
Audit `/lib/services/position_tracker.dart`:
- [ ] No timeout on anchor updates - unbounded drift possible
- [ ] Duration interpolation can exceed track duration
- [ ] Stale timestamp handling (>30 seconds)
### 8.2 Sendspin Protocol
Audit `/lib/services/sendspin_service.dart`:
- [ ] Proxy auth retry without deduplication (lines 188-209)
- [ ] WebSocket binary frame parsing
- [ ] Clock synchronization accuracy
### 8.3 Audio Resource Cleanup
Audit `/lib/services/audio/massiv_audio_handler.dart`:
- [ ] Audio session interrupt listeners not disposed
- [ ] Audio focus not explicitly released
### 8.4 Mixed Playback Modes
- [ ] just_audio initialized but unused during Sendspin
- [ ] Resource allocation for unused player
- [ ] Potential audio focus conflicts
---
## SECTION 9: NETWORKING AUDIT
### 9.1 WebSocket Lifecycle
Audit `/lib/services/music_assistant_api.dart`:
- [ ] Heartbeat interval (30s) - appropriate?
- [ ] Reconnection logic and timing
- [ ] Pending request cleanup on disconnect
### 9.2 Caching Strategy
- [ ] No incremental/delta sync (full library every 5 minutes)
- [ ] Cache staleness not communicated to UI
- [ ] No query result pagination (fetches up to 5000 items)
### 9.3 Rate Limiting
- [ ] No client-side rate limiting
- [ ] Search requests not throttled
- [ ] Could overload server
### 9.4 Offline Support
Audit `/lib/services/offline_action_queue.dart`:
- [ ] Queue execution not atomic (lines 118-130)
- [ ] Action could execute twice on crash
- [ ] Queue stored unencrypted
---
## SECTION 10: DATABASE AUDIT
### 10.1 Schema Review
Audit `/lib/database/database.dart`:
- [ ] Table indexes for query performance
- [ ] Foreign key constraints
- [ ] Migration strategy
### 10.2 Query Patterns
- [ ] N+1 query prevention
- [ ] Transaction usage for atomic operations
- [ ] Connection pooling
### 10.3 Data Consistency
- [ ] Tier 1 (DB) vs Tier 2 (API sync) race conditions
- [ ] User modifications during background sync
- [ ] Conflict resolution strategy
---
## Deliverables
After completing the audit, provide:
### 1. Executive Summary
- Overall code quality score (1-10)
- Top 5 critical issues requiring immediate attention
- Estimated technical debt in developer-hours
### 2. Issue Tracker
Create a prioritized list of all issues found:
| ID | Severity | Category | File:Line | Description | Effort |
|----|----------|----------|-----------|-------------|--------|
### 3. Recommended Roadmap
Sprint-based plan to address findings:
- Sprint 1 (Immediate): Security fixes, silent exception handlers
- Sprint 2-3: God class decomposition, test infrastructure
- Sprint 4-6: Memory leak fixes, accessibility
- Long-term: Performance optimization, monitoring
### 4. Code Examples
For each CRITICAL and HIGH severity issue, provide:
- Current problematic code
- Recommended refactored code
- Migration steps
---
## Files to Prioritize
Start your audit with these critical files:
1. `/lib/providers/music_assistant_provider.dart` (4,469 LOC) - God class
2. `/lib/services/settings_service.dart` - Credential storage
3. `/lib/services/music_assistant_api.dart` (2,929 LOC) - Networking
4. `/lib/widgets/expandable_player.dart` (2,485 LOC) - UI complexity
5. `/lib/services/auth/` directory - Security
6. `/lib/models/player.dart` - Memory leaks
7. `/lib/services/cache_service.dart` - Unbounded caches
8. `/lib/services/position_tracker.dart` - Audio sync issues
---
## Known Critical Issues (Pre-Identified)
These issues have been identified and MUST be verified and documented:
1. **CRITICAL: Plaintext credential storage** - SharedPreferences without encryption
2. **CRITICAL: Zero test coverage** - No unit, widget, or integration tests
3. **CRITICAL: 4,469 line god class** - MusicAssistantProvider violates SRP
4. **CRITICAL: Silent exception handlers** - `catch (e) {}` patterns
5. **HIGH: Static memory leak** - `_playerCreationTimes` unbounded growth
6. **HIGH: Race conditions** - Player state polling vs events
7. **HIGH: No accessibility** - Missing Semantics, screen reader support
8. **HIGH: Certificate validation disabled** - MITM vulnerability for local IPs
9. **MEDIUM: Double opacity bug** - `.withOpacity().withOpacity()` in player controls
10. **MEDIUM: Position tracker drift** - No timeout on anchor updates
---
## Audit Execution Notes
- Use `Grep` tool to search for patterns across the codebase
- Use `Read` tool to examine specific file sections
- Document line numbers for all findings
- Provide code snippets for context
- Cross-reference related issues across files
- Verify pre-identified issues exist and document their exact locations
**Total estimated audit time: 4-6 hours for thorough coverage**
+419
View File
@@ -0,0 +1,419 @@
# Ensemble Codebase - Comprehensive Audit Report
**Audit Date:** 2026-01-01
**Codebase:** Ensemble Flutter App (Music Assistant Client)
**Version:** 2.7.3-beta+35
**Total Lines of Code:** ~40,583 Dart
**Files Analyzed:** 90 Dart files
---
## Executive Summary
### Overall Code Quality Score: 5.5/10
The Ensemble codebase demonstrates **strong architectural foundations** with a well-organized service layer, effective caching strategies, and sophisticated audio handling. However, the audit revealed **critical security vulnerabilities**, **zero test coverage**, and significant **technical debt** that must be addressed before production release.
### Critical Statistics
| Metric | Value | Status |
|--------|-------|--------|
| Test Coverage | 0% | CRITICAL |
| God Class Size | 4,469 LOC | CRITICAL |
| Silent Exception Handlers | 7 | CRITICAL |
| Plaintext Credentials | 4 locations | CRITICAL |
| Memory Leak Locations | 6 | HIGH |
| Race Conditions | 4 | HIGH |
| Accessibility (Semantics) | 0 widgets | CRITICAL |
---
## Issue Summary by Severity
| Severity | Count | Categories |
|----------|-------|------------|
| **CRITICAL** | 12 | Security, Testing, Architecture, Accessibility |
| **HIGH** | 18 | Memory, Race Conditions, Networking, Audio |
| **MEDIUM** | 22 | Error Handling, UI, Caching, Code Quality |
| **LOW** | 14 | Naming, Documentation, Minor Improvements |
---
## CRITICAL ISSUES (Immediate Action Required)
### 1. Plaintext Credential Storage
**File:** `/lib/services/settings_service.dart`
**Lines:** 131-143, 147-159, 169-190, 209-222
Passwords and auth tokens stored in SharedPreferences without encryption.
```dart
// VULNERABLE CODE
static Future<void> setPassword(String? password) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyPassword, password); // PLAINTEXT!
}
```
**Fix:** Use `flutter_secure_storage` for all sensitive data.
---
### 2. Zero Test Coverage
**Location:** Entire codebase
No unit tests, widget tests, or integration tests exist. The `flutter_test` dependency is unused.
**Impact:** Cannot safely refactor, high regression risk, no confidence in correctness.
**Fix:** Establish test infrastructure immediately. Start with:
- Unit tests for `MusicAssistantAPI`, `CacheService`, `ErrorHandler`
- Widget tests for player controls
- Integration tests for playback flows
---
### 3. God Class - MusicAssistantProvider
**File:** `/lib/providers/music_assistant_provider.dart`
**Size:** 4,469 lines, ~140 methods, 744 conditional branches
Single class handles: connection, players, library, cache, audio, sync, offline queue.
**Fix:** Split into:
- `ConnectionManager` (~500 LOC)
- `PlayerManager` (~800 LOC)
- `LibraryCacheService` (~600 LOC)
- `SendspinManager` (~400 LOC)
- `MusicAssistantProvider` - Facade composing above (~800 LOC)
---
### 4. Zero Accessibility Support
**Files:** `/lib/widgets/`, `/lib/screens/` (all)
- **0** `Semantics` widgets
- **0** `semanticLabel` properties
- **1** `Tooltip` (entire app)
- Screen readers cannot navigate the app
**Fix:** Add Semantics to all interactive elements:
```dart
Semantics(
button: true,
label: isPlaying ? 'Pause playback' : 'Play',
child: IconButton(...)
)
```
---
### 5. Silent Exception Handlers
**Locations:** 7 instances across codebase
| File | Line | Code |
|------|------|------|
| player_provider.dart | 567, 575, 584 | `catch (e) {}` |
| music_assistant_provider.dart | 2690 | `catch (e) {}` |
| music_assistant_api.dart | 2542 | `catch (e) {}` |
| recently_played_service.dart | 154, 206 | `catch (_) {}` |
**Fix:** Add logging or use `firstWhereOrNull` instead of try/catch around `firstWhere`.
---
### 6. Audio Session Listeners Not Disposed
**File:** `/lib/services/audio/massiv_audio_handler.dart`
**Lines:** 45-73
Stream subscriptions created but never cancelled - causes memory leaks and zombie listeners.
**Fix:** Store subscriptions and cancel in dispose():
```dart
StreamSubscription? _interruptionSubscription;
_interruptionSubscription = session.interruptionEventStream.listen(...);
void dispose() {
_interruptionSubscription?.cancel();
}
```
---
## HIGH SEVERITY ISSUES
### 7. Connection State Subscription Not Stored
**File:** `/lib/providers/music_assistant_provider.dart:575-614`
The `connectionState.listen()` return value is not stored - causes listener accumulation on reconnect.
---
### 8. Player State Race Condition
**File:** `/lib/providers/music_assistant_provider.dart`
Timer polling (every 5s) and WebSocket events can race to update `_selectedPlayer` and `_currentTrack` without mutex protection.
---
### 9. Async Method Reentrancy
**File:** `/lib/providers/music_assistant_provider.dart:2713-2841`
`selectPlayer()` is `void async` with no guard against concurrent calls. Multiple rapid player switches can interleave.
---
### 10. Unbounded Search Cache
**File:** `/lib/services/cache_service.dart:31-32`
`_searchCache` map has no size limit or proactive TTL eviction. Grows indefinitely with user searches.
---
### 11. Static Memory Leak
**File:** `/lib/models/player.dart:96, 139-144`
`_playerCreationTimes` static map cleanup only removes 50 of 100+ entries, allowing unbounded growth.
---
### 12. No Database Indexes
**File:** `/lib/database/database.dart`
No secondary indexes defined. Query performance degrades as database grows.
---
### 13. No Incremental Sync
**File:** `/lib/services/sync_service.dart:150-165`
Full library fetch (up to 4000 items) on every sync instead of delta updates.
---
### 14. Offline Queue Not Atomic
**File:** `/lib/services/offline_action_queue.dart:80-108`
Queue persistence happens after all processing - crash during processing loses successful actions.
---
### 15. Auth Retry Without Deduplication
**File:** `/lib/services/sendspin_service.dart:189-209`
Multiple auth messages can be sent during rapid reconnection attempts.
---
### 16. Pending Requests Not Completed on Disconnect
**File:** `/lib/services/music_assistant_api.dart:2911`
`_pendingRequests.clear()` abandons Completers without completing them with errors.
---
### 17. HTTP Fallback for Local IPs
**File:** `/lib/services/auth/auth_manager.dart:51-58`
HTTPS failures silently fall back to HTTP on local networks - MITM vulnerability.
---
### 18. Certificate Validation Bypassed
**File:** `/lib/services/auth/auth_manager.dart:210-236`
HTTPS redirects explicitly refused for local IPs due to expected certificate failures.
---
## MEDIUM SEVERITY ISSUES
### 19. Double Opacity Bug
**File:** `/lib/widgets/player/player_controls.dart:62-63, 99-100`
```dart
// BUG: .withOpacity(0.5).withOpacity(expandedElementsOpacity) compounds opacity
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
.withOpacity(expandedElementsOpacity),
```
---
### 20. Mixed Async Patterns
**Locations:** 13 instances
`.then()` chains mixed with `async/await` throughout the codebase.
---
### 21. Hardcoded Colors
**Locations:** 55 instances
`Color(0xFF604CEC)`, `Color(0xFF1a1a1a)` scattered across theme files and widgets.
---
### 22. View Modes as Strings
**Locations:** 80+ instances
`'grid2'`, `'grid3'`, `'list'` should be enums.
---
### 23. Fire-and-Forget Pause Commands
**File:** `/lib/providers/music_assistant_provider.dart:3875-3894`
Server pause command uses `unawaited()` - errors are silently lost.
---
### 24. Position Tracker Anchor Timeout Missing
**File:** `/lib/services/position_tracker.dart:24-26`
No timeout on stale anchor - interpolation can drift indefinitely.
---
### 25. PCM Buffer No Overflow Protection
**File:** `/lib/services/pcm_audio_player.dart:68, 236-247`
Audio buffer grows unbounded if data arrives faster than consumption.
---
### 26. No Client-Side Rate Limiting
**Location:** App-wide
No throttling on API requests during rapid user interactions.
---
### 27. Portrait-Only Lock
**File:** `/lib/main.dart:64-68`
No landscape orientation support.
---
### 28. Touch Targets Below 48dp
**Files:** player_controls.dart, queue_panel.dart, player_card.dart
Multiple buttons at 28x28 and 44x44 instead of minimum 48x48.
---
### 29. WCAG Contrast Not Validated
**File:** `/lib/theme/palette_helper.dart`
Uses 0.4 threshold instead of proper WCAG 4.5:1 contrast ratio calculation.
---
### 30. StreamControllers Not Closed
**File:** `/lib/services/hardware_volume_service.dart:18-19, 74-85`
`_volumeUpController` and `_volumeDownController` never closed in dispose().
---
## LOW SEVERITY ISSUES
### 31-34. Naming Inconsistencies
Mixed patterns: `_isLoading` vs `_isLoadingPlaylists`, various cache variable names.
### 35-38. Missing TODO Implementations
6 actionable TODOs identified (queue removal, audiobook progress sync, playlist modification).
### 39-42. Dead/Unused Code
- `@Deprecated` methods still implemented
- `library_stats.dart` appears unused
- `animation_debugger.dart` disabled by default
### 43-44. Minor Documentation Gaps
Missing method documentation, no architecture diagram.
---
## Recommended Remediation Roadmap
### Sprint 1 (Week 1-2): Security & Critical Fixes
1. Migrate credentials to `flutter_secure_storage`
2. Add logging to all silent catch blocks
3. Store connection state subscription
4. Fix audio session listener disposal
5. Add basic test infrastructure (10% coverage target)
### Sprint 2 (Week 3-4): Memory & Race Conditions
6. Add mutex to player state updates
7. Add reentrancy guard to `selectPlayer()`
8. Implement bounded caches with LRU eviction
9. Fix static memory leak in Player model
10. Store and cancel all stream subscriptions
### Sprint 3 (Week 5-6): Architecture Refactoring
11. Split `MusicAssistantProvider` into 5 smaller classes
12. Split `expandable_player.dart` into component widgets
13. Replace GlobalKey anti-patterns with Provider
14. Convert string constants to enums
15. Extract hardcoded colors to constants file
### Sprint 4 (Week 7-8): Accessibility & UI
16. Add Semantics to all interactive elements
17. Increase touch targets to 48dp minimum
18. Implement WCAG contrast validation
19. Fix double opacity bug
20. Add landscape orientation support
### Sprint 5 (Week 9-10): Performance & Polish
21. Add database indexes
22. Implement incremental sync
23. Add client-side rate limiting
24. Fix atomic offline queue processing
25. Achieve 30% test coverage target
---
## Files Requiring Most Attention
| File | LOC | Issues | Priority |
|------|-----|--------|----------|
| `music_assistant_provider.dart` | 4,469 | 12 | CRITICAL |
| `settings_service.dart` | 811 | 4 | CRITICAL |
| `expandable_player.dart` | 2,485 | 6 | HIGH |
| `music_assistant_api.dart` | 2,929 | 5 | HIGH |
| `cache_service.dart` | 370 | 4 | HIGH |
| `massiv_audio_handler.dart` | 296 | 2 | CRITICAL |
| `player.dart` | 422 | 2 | HIGH |
| `auth_manager.dart` | 300 | 3 | HIGH |
---
## Estimated Technical Debt
| Category | Effort (Dev-Days) |
|----------|-------------------|
| Security Fixes | 3-5 |
| Test Infrastructure | 10-15 |
| Architecture Refactoring | 15-20 |
| Memory Leak Fixes | 3-5 |
| Accessibility | 5-8 |
| UI/UX Polish | 3-5 |
| Performance | 5-8 |
| **Total** | **44-66 dev-days** |
---
## Conclusion
The Ensemble app has a solid foundation with well-designed patterns for caching, audio handling, and multi-device synchronization. However, **critical security vulnerabilities** (plaintext credentials), **zero test coverage**, and a **4,469-line god class** represent significant risks that must be addressed before any production deployment.
The most urgent priorities are:
1. **Secure credential storage** - immediate security risk
2. **Add test infrastructure** - enables safe refactoring
3. **Split the god class** - enables maintainability
4. **Add accessibility** - legal/compliance requirement
With focused effort over 8-10 weeks, the codebase can be brought to production-quality standards.
---
*Audit conducted using 6 parallel specialized sub-agents analyzing security, memory management, race conditions, architecture, UI/UX, and audio/networking.*
+119
View File
@@ -0,0 +1,119 @@
# Ensemble Codebase Audit Prompt
Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app.
---
## Prompt
You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files.
**CRITICAL RULES:**
1. DO NOT remove or break any existing functionality
2. All changes must be on the current branch: `audit/code-quality-review`
3. Run `flutter analyze` after each set of changes to verify no regressions
4. Commit changes in logical, reviewable chunks
**AUDIT PHASES:**
### Phase 1: Code Quality Analysis (Read-Only)
First, analyze the codebase without making changes. Create a report covering:
1. **Large File Analysis** - Review these files for refactoring opportunities:
- `lib/screens/new_library_screen.dart` (122KB)
- `lib/screens/search_screen.dart` (129KB)
- `lib/widgets/expandable_player.dart` (107KB)
- `lib/screens/album_details_screen.dart` (48KB)
- `lib/screens/artist_details_screen.dart` (45KB)
2. **Pattern Inconsistencies** - Look for:
- Inconsistent naming conventions (camelCase vs snake_case)
- Mixed widget composition patterns
- Inconsistent error handling approaches
- Duplicate code across screens/widgets
3. **Performance Red Flags** - Identify:
- Unnecessary rebuilds (missing const constructors)
- Heavy computations in build methods
- Missing list item caching (ListView.builder vs ListView)
- Inefficient state management patterns
4. **Scroll Performance Issues** - Specifically audit:
- ScrollController usage and disposal
- Sliver implementations
- AnimatedBuilder/AnimatedWidget usage
- Image loading in scrollable lists
- Any jank-inducing patterns
### Phase 2: Quick Wins (Low Risk, High Impact)
Apply these fixes first:
1. Add missing `const` constructors throughout
2. Fix any `flutter analyze` warnings
3. Remove dead code and unused imports
4. Standardize formatting with `dart format`
### Phase 3: Scroll Animation Optimization
Focus on smoother scrolling:
1. **Image Optimization**
- Ensure CachedNetworkImage is used consistently
- Add appropriate cacheWidth/cacheHeight
- Implement proper placeholder/error widgets
2. **List Performance**
- Convert any ListView to ListView.builder where appropriate
- Add itemExtent where possible for fixed-height items
- Implement AutomaticKeepAliveClientMixin where needed
- Consider RepaintBoundary for complex list items
3. **Animation Smoothing**
- Use `Curves.easeOutCubic` or similar for scroll-linked animations
- Ensure animations run at 60fps (avoid heavy computations during animation)
- Review CustomScrollView/Sliver implementations
### Phase 4: Code Consistency Improvements
1. **Widget Extraction**
- Extract repeated UI patterns into reusable widgets
- Ensure extracted widgets follow existing naming patterns
2. **State Management**
- Verify Provider usage is consistent
- Check for proper disposal of controllers/streams
3. **Error Handling**
- Standardize try/catch patterns
- Ensure consistent loading/error state UI
### Phase 5: Verification
After all changes:
1. Run `flutter analyze` - must pass with 0 issues
2. Run `flutter test` if tests exist
3. Create a summary of all changes made
4. List any recommendations that were NOT implemented (too risky)
---
## Execution Instructions
```bash
# Start the audit
cd /home/home-server/Ensemble
git checkout audit/code-quality-review
# After each phase, commit your changes
git add -A && git commit -m "Audit Phase X: [description]"
# Verify no regressions
flutter analyze
```
---
## Expected Deliverables
1. `AUDIT_REPORT.md` - Findings from Phase 1
2. Multiple commits with clear messages for each phase
3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations
+257
View File
@@ -0,0 +1,257 @@
# Ensemble Codebase Audit Report
**Date**: 2026-01-14
**Scope**: Full codebase audit (104 Dart files, 18 screens)
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
---
## Executive Summary
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
| Severity | Count | Key Themes |
|----------|-------|------------|
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
| **Low** | 15+ | Minor polish items |
**Top 3 Priorities:**
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
---
## Critical Issues
### 1. God Object: MusicAssistantProvider (5,443 lines)
**File**: `lib/providers/music_assistant_provider.dart`
- 54 `notifyListeners()` calls
- 30+ state variables
- 100+ public methods
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
### 2. God Object: NewLibraryScreen (4,209 lines)
**File**: `lib/screens/new_library_screen.dart`
- 10 scroll controllers
- 15+ view mode states
- 15+ sort order states
- Handles: Music, Books, Podcasts, Radio tabs
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
### 3. Sequential Blocking Startup (500-1500ms wasted)
**File**: `lib/main.dart:33-66`
```dart
await DatabaseService.instance.initialize();
await ProfileService.instance.migrateFromOwnerName();
await SettingsService.migrateToSecureStorage();
await SyncService.instance.loadFromCache();
audioHandler = await AudioService.init(...);
```
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
### 4. Design Tokens Exist But Unused
**File**: `lib/theme/design_tokens.dart`
- `Spacing.*` used in only ~6 of 34+ widget files
- `Radii.*` used in only ~5 files
- `IconSizes.*` used in only ~4 files
- **Fix**: Systematic replacement of hardcoded values
### 5. Touch Targets Too Small (32x32)
**File**: `lib/screens/new_library_screen.dart:1805-1867`
- Sort, favorites, view mode buttons are 32x32
- Material Design minimum: 48x48
- **Fix**: Increase to 48x48 or add padding for hit area
### 6. No Batch Database Inserts
**File**: `lib/services/sync_service.dart:212-273`
- 1000 albums = 1000 separate DB transactions
- **Fix**: Wrap in single transaction or use Drift batch insert
---
## High Severity Issues
### Performance
| Issue | File | Impact |
|-------|------|--------|
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
### Architecture
| Issue | File | Impact |
|-------|------|--------|
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
### UI/UX
| Issue | File | Impact |
|-------|------|--------|
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
### Error Handling
| Issue | File | Impact |
|-------|------|--------|
| No Flutter error boundaries | main.dart | Red error screen on build failure |
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
---
## Medium Severity Issues
### Performance
- PaletteGenerator on main isolate (10-30ms blocking)
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
- AppStartup polling loop with fixed 250ms delay
- SystemThemeHelper FutureBuilder recreated on every build
### UI Consistency
- Border radius varies: 8, 12, 20, 22, 25 for same element types
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
- Hardcoded colors for status (Colors.red, Colors.green)
- Inconsistent icon sizes across screens
### State Management
- Over-notification (multiple notifyListeners in single methods)
- Wide Consumer scope (watch instead of select)
- Missing dispose() in some providers
- Inconsistent async state handling (loading/error)
### Navigation
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
- `popToHome()` doesn't clear adaptive colors
- Infinite navigation loop possible (Album → Artist → Album)
- Orphaned Hero animations in podcast_card, radio_station_card
### Data/Caching
- Unbounded `_podcastCoverCache`
- SyncService listener not removed on error
- Redundant API calls between providers
- Fire-and-forget persistence race condition
### Error Handling
- No zone error handler for async exceptions
- `retryCritical()` retries all errors including non-recoverable
- No user notification of offline queue status
- DisconnectedState not used consistently
---
## Quick Wins (High Impact, Low Effort)
| # | Task | File(s) | Effort |
|---|------|---------|--------|
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
| 5 | Add Flutter error boundary | main.dart | 30 min |
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
---
## Refactoring Roadmap
### Phase 1: Quick Wins (1-2 days)
- Fix touch targets
- Fix bottom spacing
- Add navigation debounce
- Add error boundaries
- Cache SharedPreferences singleton
### Phase 2: Performance (3-5 days)
- Parallelize startup operations
- Replace `context.watch()` with `context.select()`
- Batch database inserts
- Add SecureStorage migration skip flag
- Fix over-notification in provider
### Phase 3: Design System (1 week)
- Systematic design token adoption
- Standardize button sizes (44 vs 50)
- Standardize border radius values
- Create StatusColors semantic tokens
- Replace all magic numbers
### Phase 4: Architecture (2-3 weeks)
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
---
## Positive Findings
The codebase already demonstrates many good practices:
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
- **Theme system**: Material You support, adaptive colors from album art
- **Offline support**: Cache-first loading, graceful degradation
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
---
## Files by Priority
| Priority | File | Lines | Key Issues |
|----------|------|-------|------------|
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
| 3 | main.dart | 549 | Startup blocking, error boundaries |
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
| 8 | All detail screens | Various | Hardcoded bottom spacing |
| 9 | All card widgets | Various | Navigation debounce, design tokens |
---
## Audit Methodology
This audit was conducted using 9 parallel sub-agents analyzing:
**UI Continuity (3 agents)**
1. Navigation & Flow - back button, Hero animations, tab preservation
2. Visual Consistency - design tokens, spacing, typography
3. Component Placement - touch targets, bottom spacing, modals
**Performance (3 agents)**
4. Rendering Performance - ListView.builder, const, rebuilds
5. Data & Caching - streams, invalidation, memory bounds
6. Startup & Load Time - blocking operations, initialization order
**Logic/Best Practices (3 agents)**
7. State Management - Provider patterns, notification frequency
8. Error Handling - try-catch, boundaries, resilience
9. Code Quality - file size, duplication, SOLID principles
---
## Conclusion
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
1. **Splitting the two 4,000+ line files** into focused components
2. **Adopting design tokens systematically** for visual consistency
3. **Optimizing startup sequence** for faster app launch
4. **Using granular state management** (select vs watch) for better performance
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
+57
View File
@@ -0,0 +1,57 @@
# Audit Summary
**Branch:** `audit/code-quality-review`
**Date:** January 4, 2026
## Changes Applied
### Phase 1: Analysis Complete
- Created `AUDIT_REPORT.md` with comprehensive findings
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
### Phase 2: Deprecated API Fixes (18 files)
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
**Files modified:**
- lib/screens/library_artists_screen.dart
- lib/screens/library_albums_screen.dart
- lib/screens/library_playlists_screen.dart
- lib/screens/library_tracks_screen.dart
- lib/screens/artist_details_screen.dart
- lib/screens/new_home_screen.dart
- lib/screens/login_screen.dart
- lib/screens/settings_screen.dart
- lib/screens/album_details_screen.dart
- lib/screens/search_screen.dart
- lib/screens/new_library_screen.dart
### Phase 3: Scroll Performance Optimization
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
**lib/screens/new_library_screen.dart:**
- Line 1400-1401: Author list tile image (128x128 cache)
- Line 1487-1488: Author grid card image (256x256 cache)
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
- Line 1749-1750: Audiobook grid cover (512x512 cache)
- Line 1970-1971: Series list tile cover (256x256 cache)
- Line 2986-2987: Album list tile cover (256x256 cache)
## Recommendations NOT Implemented (Low Priority)
These were identified but NOT changed to preserve functionality:
1. **View mode cycling refactor** - 8 similar functions could be consolidated
2. **Color constant extraction** - Duplicated colors in 3 files
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
4. **BorderRadius.circular const** - 40+ instances
## Verification
Changes should be verified by:
1. GitHub Actions build (triggered automatically)
2. Manual UI testing of scroll smoothness
3. Verify no visual regressions in dark/light themes
## Build Trigger
GitHub Actions workflow triggered on branch `audit/code-quality-review`
-167
View File
@@ -1,167 +0,0 @@
# Music Assistant 2.8.0 Beta 14 — Impact on Ensemble
**Date:** 2026-03-03
**Current server:** 2.8.0 beta 12
**Target server:** 2.8.0 beta 14 (released March 2, 2026)
---
## Summary
Beta 14 is a large release (50+ PRs). The headline change — merging multi-protocol players into a single entity — directly overlaps with Ensemble's client-side Cast↔Sendspin consolidation logic. Sendspin also received a major v4.0 overhaul. Most changes should degrade gracefully but player ID persistence will break on upgrade.
---
## Breaking: Player Merging (PR #3150)
MA now merges players that support multiple protocols (e.g. a Chromecast with Cast + Sendspin) into a **single player entity** server-side. Previously these appeared as two separate players.
> *"After this change, all your players will be reset. If you previously had multiple players in Music Assistant per protocol, this will now show up as one single player."*
### What Ensemble does today
Ensemble handles this client-side in `music_assistant_provider.dart:4271-4406`:
- `_castToSendspinIdMap` maps Cast IDs → Sendspin IDs
- When Sendspin is grouped: hides Cast player, shows Sendspin (renamed to base name)
- When ungrouped: hides Sendspin, shows original Cast
- Mappings persisted to database for cold start restoration
### Impact on Ensemble
| Component | Location | Impact | Severity |
|---|---|---|---|
| `_castToSendspinIdMap` + consolidation logic | `provider:4271-4406` | Dead code — server no longer sends separate players | Medium |
| `isManuallySyncedPlayer()` ID translation | `provider:533-595` | Lookups against stale map fail silently (returns `false`) | Low |
| Player selection persistence | Settings + DB | **Stored player IDs invalid after upgrade** — all players reset | **High** |
| `_handlePlayerUpdatedEvent()` ID matching | `provider:2956-3358` | Events arrive with new merged IDs, old translation won't match | Medium |
| Database-persisted Cast↔Sendspin mappings | `DatabaseService` | Stale data, won't match anything | Low |
### Assessment
The consolidation logic should degrade gracefully — it won't find Sendspin suffixes to strip or Cast pairs to merge, so the server's already-merged players pass through `filterPlayers()` as-is. The main risk is **player selection breaking** because saved player IDs no longer exist.
### Action required
1. Verify the existing fallback logic (`provider:4427-4465`) gracefully re-selects when the stored `selectedPlayerId` doesn't exist in the player list
2. Remove or gate the Cast↔Sendspin consolidation code behind a server version check (on beta 14+ it's unnecessary and could cause confusion)
3. Clear stale database mappings on first connection to a beta 14+ server
---
## Sendspin v4.0 (PR #3158)
Major rewrite of the Sendspin server.
### Changes
- New `SendspinPlaybackSession` coordinator architecture
- Per-member DSP pipelines, dynamic group membership, late-join audio backfill
- Max buffer capacity 5 → 30 seconds
- Multiple Sendspin servers with automatic failover
- Per-client codec/sample-rate/bit-depth options
- Album artwork persists during skip/pause
- Reduced latency for live streams
### Impact on Ensemble
| Change | Impact |
|---|---|
| New session architecture | **Likely compatible** — sendspin-js only bumped 2.0.0 → 2.0.1 (minor), suggesting wire protocol didn't fundamentally change |
| Per-client codec options | `stream/start` may contain new fields — safe if Ensemble ignores unknown fields |
| Multiple servers with failover | Ensemble's 3-tier connection strategy may need awareness |
| Buffer increase | No client impact |
### Action required
1. Test Sendspin connection against beta 14 — verify `client/hello`, `stream/start`, state reporting all still work
2. Confirm unknown fields in protocol messages are ignored gracefully
---
## Sendspin-Chromecast Bridge (PR #3255)
Replaces the inline experimental Sendspin mode in `ChromecastPlayer` with a dedicated bridge. Cast devices now use a Cast receiver app with an embedded JS Sendspin client.
### Impact on Ensemble
- **Direct impact: None** — Ensemble is a Sendspin client itself, not going through Cast
- **Indirect:** Changes how grouped Cast players behave, which could affect `GroupVolumeManager`
---
## Group Volume / Grouping Fixes
| PR | Change | Ensemble Impact |
|---|---|---|
| #3192 | Fix grouping for players whose native protocol is also a protocol of other players | Bidirectional grouping translation — may affect group detection logic |
| #3205 | Fix group mute for protocol-synced players (`player.state.group_members` vs `player.group_members`) | `GroupVolumeManager` may see different/correct member lists |
| #3277 | Fix `volume_up`/`volume_down` for group players | Server-side fix, should improve behavior |
| #3118 | Fix sync groups losing members on power off | Affects dynamic group management |
### Action required
1. Test group volume behavior — may "just work better" with server-side fixes
2. Verify `GroupVolumeManager` handles any changes to `group_members` field population
---
## Sendspin Bug Fixes
| PR | Fix | Ensemble Relevance |
|---|---|---|
| #3191 | DSP not applying for Sendspin players | DSP config stored on parent player, looked up by protocol player ID — now fixed |
| #3249 | Update to aiosendspin 4.2.0 | Library update, potential behavior changes |
| #3250 | Metadata sending wrong progress when paused | Progress reporting fix — Ensemble's position tracker may see better data |
| #3258 | Floating point for internal audio data | Audio quality improvement, no client impact |
---
## New APIs — Opportunities
| Feature | PR | Details | Ensemble Opportunity |
|---|---|---|---|
| Playback speed | #3198 | `player_queues/set_playback_speed`, stored in `queue.extra_attributes["playback_speed"]` | Great for audiobooks/podcasts |
| Save queue to playlist | #3149 | `save_as_playlist` command on player queue controller | Easy feature add |
| Mixed-media playlists | #3216 | Playlists can contain tracks + podcasts + audiobooks. DB schema bumped to v30. `get_playlist_tracks()` returns `Sequence[PlaylistPlayableItem]` | Playlist screens may need to handle heterogeneous items |
| Genres v2 | #3164 | New browsing category with icons and SVG support | New library section |
---
## Also Changed in Beta 13 (Skipped)
Relevant changes from beta 13 (between current beta 12 and target beta 14):
- Group volume mute support (#3034)
- Frontend prepared for multiprotocol support (#1409) — groundwork for beta 14 merging
- IPv6 support for zeroconf, stream server, AirPlay (#3086)
- Snapcast player availability alignment (#3104)
- DLNA reconnection fix (#3132)
---
## Recommended Action Plan
### Before upgrading
- [ ] Verify player selection fallback handles missing player IDs gracefully
- [ ] Back up current configuration
### Immediate (after upgrading)
- [ ] Test Sendspin protocol compatibility (connection, streaming, state reporting)
- [ ] Test player list — confirm merged players appear correctly without client-side consolidation
- [ ] Test group volume behavior with new server-side fixes
- [ ] Test player selection persistence across app restarts
### Short-term
- [ ] Remove or version-gate Cast↔Sendspin consolidation code
- [ ] Clear stale database mappings on beta 14+ detection
- [ ] Test mixed-media playlist handling if playlists are used
### Opportunistic
- [ ] Add playback speed control (audiobook/podcast feature)
- [ ] Add save-queue-to-playlist
- [ ] Explore Genres v2 browsing
+15 -38
View File
@@ -1,12 +1,6 @@
<div align="center"> <div align="center">
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200"> <img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
[![GitHub release](https://img.shields.io/badge/Release-v3.0.4-blue?style=for-the-badge&logo=github)](https://github.com/CollotsSpot/Ensemble/releases)
[![GitHub Downloads](https://img.shields.io/github/downloads/CollotsSpot/Ensemble/latest/total?style=for-the-badge&logo=android&label=APK%20Downloads&color=green)](https://github.com/CollotsSpot/Ensemble/releases/latest)
[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub-EA4AAA?style=for-the-badge&logo=GitHub%20Sponsors&logoColor=white)](https://github.com/sponsors/CollotsSpot)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF5E5B?style=for-the-badge&logo=Ko-fi&logoColor=white)](https://ko-fi.com/collotsspot)
--- ---
<p><strong>An unofficial mobile client for Music Assistant</strong></p> <p><strong>An unofficial mobile client for Music Assistant</strong></p>
@@ -23,12 +17,6 @@ This application was built with AI-assisted development using **Claude Code** an
--- ---
## Also See
**[Ensemble TV](https://github.com/CollotsSpot/ensemble-tv)** - A simple Android TV client for Music Assistant that displays what's currently playing on your TV with remote control support.
---
## Features ## Features
### Local Playback ### Local Playback
@@ -86,13 +74,6 @@ This application was built with AI-assisted development using **Claude Code** an
- **Skip Controls** - Skip forward/backward during playback - **Skip Controls** - Skip forward/backward during playback
- **High-Resolution Artwork** - Fetched via iTunes for best quality - **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 ### Smart Features
- **Instant App Restore** - App loads instantly with cached library data while syncing in background - **Instant App Restore** - App loads instantly with cached library data while syncing in background
- **Auto-Reconnect** - Automatically reconnects when connection is lost - **Auto-Reconnect** - Automatically reconnects when connection is lost
@@ -146,22 +127,25 @@ To find the correct URL:
If you run Music Assistant as a Home Assistant add-on: If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server - Use the IP address of your Home Assistant server
- Enter `8095` in the port field
- Do **not** use your Home Assistant URL or ingress URL - 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 ### 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). For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
## Authentication
Ensemble supports multiple authentication methods:
| Method | Status |
|--------|--------|
| Music Assistant native auth | Tested |
| No authentication | Tested |
| Authelia | Implemented, not recently tested |
| HTTP Basic Auth | Implemented, not recently tested |
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
## Requirements ## Requirements
@@ -170,13 +154,6 @@ For access outside your home network, you'll need to expose Music Assistant thro
- Android device (Android 5.0+) - Android device (Android 5.0+)
- Audiobookshelf provider configured in Music Assistant (for audiobook features) - Audiobookshelf provider configured in Music Assistant (for audiobook features)
## Sponsors
A huge thank you to my sponsors! 💖
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
<a href="https://github.com/githubchb"><img src="https://github.com/githubchb.png" width="60px" alt="githubchb" /></a>
## License ## License
MIT License MIT License
-41
View File
@@ -2,33 +2,6 @@
## v2.9.2-beta (TBD) ## v2.9.2-beta (TBD)
### New Features
#### Context Menus
- **Long-press context menus on all media items**: Albums, artists, tracks, playlists, podcasts, and radio stations now all support long-press to show a context menu with Play, Add to Library, and Favorite options.
#### Podcasts
- **Podcast episode detail screen**: Tap "View Details" from episode context menu to see full scrollable description, publish date, duration, with Play, Play On, and Add to Queue actions.
- **Episode sorting**: Sort podcast episodes by newest first, oldest first, alphabetical, or duration.
#### Audio Quality
- **Hi-res audio badge**: Track listings now show a hi-res badge when high quality audio is available.
### Improvements
#### UI/UX
- **New library icon**: Replaced confusing library icon with clearer book icon (book_2) across bottom nav, search filter, detail screens, and context menus. Filled when in library, outlined when not.
- **Player selector**: Replaced popup dialog with player select overlay for queue actions.
- **Search library filter**: Filled icon indicates active filter state without background highlight.
- **Context menu polish**: Slightly larger buttons (46px), wider menu (185px), consistent text colors that don't pick up adaptive theming.
- **Navigation transitions**: Smoother fade/slide animations between screens.
- **Hero animations**: Podcast episode images animate smoothly to detail screen.
#### Performance
- **Library list optimization**: Standardized list item heights with itemExtent, reduced image cache sizes for smoother scrolling.
- **Palette extraction**: Moved to isolate for better UI responsiveness during color extraction.
- **Provider selects**: Optimized provider state watching to reduce unnecessary rebuilds.
### Bug Fixes ### Bug Fixes
#### Audiobooks #### Audiobooks
@@ -56,23 +29,9 @@
#### Provider Management #### Provider Management
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings. - **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
#### Playback
- **Fixed Spotify external source detection** (issue #27): Properly detects when Spotify is the music provider.
- **Fixed foreground service not stopping** (issue #51): Auto-stops after 30 minutes idle.
- **Fixed Sendspin proxy auth**: Now works correctly for local network connections.
#### UI Fixes
- **Fixed nav bar hidden when no player selected**: Navigation bar now always visible.
- **Fixed Flutter 3.38 slider grey rectangles**: Volume and seek sliders render correctly.
- **Fixed library tab scroll position**: Maintains position when switching tabs.
### Technical Changes ### Technical Changes
- Flutter 3.38 compatibility
- Simplified authentication using MA built-in auth
- Added `material_symbols_icons` package for book_2 icon
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups - Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data - Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
- Provider filter now validates against available providers when toggling, not just on initial load - Provider filter now validates against available providers when toggling, not just on initial load
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates - Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
- Options menu provider toggles now use Consumer to watch Provider state directly - Options menu provider toggles now use Consumer to watch Provider state directly
- Centralized player selector logic in GlobalPlayerOverlay.showPlayerSelectorForAction()
+3 -18
View File
@@ -29,8 +29,8 @@ if (keystorePropertiesFile.exists()) {
} }
android { android {
namespace "com.collotsspot.ensemble" namespace "com.musicassistant.music_assistant"
compileSdk 36 compileSdk 35
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
compileOptions { compileOptions {
@@ -58,7 +58,7 @@ android {
} }
defaultConfig { defaultConfig {
applicationId "com.collotsspot.ensemble" applicationId "com.musicassistant.music_assistant"
minSdkVersion flutter.minSdkVersion minSdkVersion flutter.minSdkVersion
targetSdk 35 targetSdk 35
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
@@ -70,21 +70,6 @@ android {
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
} }
} }
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86_64': 3]
android.applicationVariants.configureEach { variant ->
variant.outputs.each { output ->
def abiVersionCode = abiCodes[output.getFilter('ABI')]
if (abiVersionCode != null) {
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
} }
flutter { flutter {
-10
View File
@@ -1,11 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
@@ -54,10 +48,6 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
@@ -1,229 +0,0 @@
package com.collotsspot.ensemble
import android.content.Context
import android.database.ContentObserver
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
// while also intercepting volume button events
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
private var methodChannel: MethodChannel? = null
private var isListening = false
// Volume observer for lockscreen volume changes
// Watches system STREAM_MUSIC volume and mirrors changes to the MA player
// when a remote/group player is active
private var audioManager: AudioManager? = null
private var volumeObserver: VolumeContentObserver? = null
private var isObservingVolume = false
private var lastKnownVolume: Int = -1
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
private var ignoringVolumeChange = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
methodChannel?.setMethodCallHandler { call, result ->
Log.d(TAG, "Received method call: ${call.method}")
when (call.method) {
"startListening" -> {
isListening = true
Log.d(TAG, "Volume listening ENABLED")
result.success(null)
}
"stopListening" -> {
isListening = false
Log.d(TAG, "Volume listening DISABLED")
result.success(null)
}
"startVolumeObserver" -> {
// Start observing system volume for lockscreen volume changes.
// Optional: initialVolume (0-100) sets the system volume to match
// the MA player's current volume for a consistent HUD display.
val initialVolume = call.argument<Int>("initialVolume")
startVolumeObserver(initialVolume)
result.success(null)
}
"stopVolumeObserver" -> {
stopVolumeObserver()
result.success(null)
}
"syncSystemVolume" -> {
// Sync system volume to match MA player volume (0-100)
val volume = call.argument<Int>("volume") ?: 0
syncSystemVolume(volume)
result.success(null)
}
else -> {
Log.d(TAG, "Unknown method: ${call.method}")
result.notImplemented()
}
}
}
}
/// Start observing system STREAM_MUSIC volume changes.
/// When a change is detected, sends an "absoluteVolumeChange" event to Flutter
/// with the new volume mapped to 0-100 scale.
private fun startVolumeObserver(initialVolume: Int?) {
if (isObservingVolume) return
val am = audioManager ?: return
// Optionally set system volume to match the MA player's current volume
if (initialVolume != null) {
syncSystemVolume(initialVolume)
}
lastKnownVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
volumeObserver = VolumeContentObserver(Handler(Looper.getMainLooper()))
contentResolver.registerContentObserver(
Settings.System.CONTENT_URI,
true,
volumeObserver!!
)
isObservingVolume = true
Log.d(TAG, "Volume observer STARTED, initial system volume: $lastKnownVolume")
}
/// Stop observing system volume changes.
private fun stopVolumeObserver() {
if (!isObservingVolume) return
volumeObserver?.let {
contentResolver.unregisterContentObserver(it)
}
volumeObserver = null
isObservingVolume = false
Log.d(TAG, "Volume observer STOPPED")
}
/// Set the system STREAM_MUSIC volume to match an MA player volume (0-100).
/// Maps MA's 0-100 range to the device's 0-maxVolume range.
private fun syncSystemVolume(maVolume: Int) {
val am = audioManager ?: return
val maxSystemVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val systemVolume = (maVolume * maxSystemVolume / 100).coerceIn(0, maxSystemVolume)
ignoringVolumeChange = true
am.setStreamVolume(AudioManager.STREAM_MUSIC, systemVolume, 0)
lastKnownVolume = systemVolume
ignoringVolumeChange = false
Log.d(TAG, "Synced system volume: MA $maVolume% -> system $systemVolume/$maxSystemVolume")
}
/// ContentObserver that watches for system volume changes.
/// When volume changes (e.g., from lockscreen hardware buttons),
/// maps the change to a 0-100 value and sends to Flutter.
inner class VolumeContentObserver(handler: Handler) : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) {
super.onChange(selfChange)
if (ignoringVolumeChange) return
val am = audioManager ?: return
val currentVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
if (currentVolume != lastKnownVolume) {
val maxVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val maVolume = if (maxVolume > 0) (currentVolume * 100 / maxVolume) else 0
Log.d(TAG, "Volume observer: system $lastKnownVolume -> $currentVolume (MA: $maVolume%)")
lastKnownVolume = currentVolume
// Send the absolute volume (0-100) to Flutter
methodChannel?.invokeMethod("absoluteVolumeChange", maVolume)
}
}
}
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
// and may consume events before they reach onKeyDown
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
if (event == null) {
return super.dispatchKeyEvent(event)
}
val keyCode = event.keyCode
val action = event.action
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
if (action != KeyEvent.ACTION_DOWN) {
// For volume keys when listening, also consume ACTION_UP to fully block system volume
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
Log.d(TAG, "Consuming ACTION_UP for volume key")
return true
}
return super.dispatchKeyEvent(event)
}
if (!isListening) {
Log.d(TAG, "Not listening, passing to super")
return super.dispatchKeyEvent(event)
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeUp", null)
true // Consume the event to prevent system volume change
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeDown", null)
true // Consume the event to prevent system volume change
}
else -> {
Log.d(TAG, "Other key, passing to super")
super.dispatchKeyEvent(event)
}
}
}
// Pause volume interception when app goes to background so hardware buttons
// only control the system volume (e.g., YouTube, phone ringer) as expected.
private var wasListeningBeforePause = false
private var wasObservingBeforePause = false
override fun onPause() {
wasListeningBeforePause = isListening
wasObservingBeforePause = isObservingVolume
isListening = false
stopVolumeObserver()
Log.d(TAG, "onPause: suspended volume interception (wasListening=$wasListeningBeforePause, wasObserving=$wasObservingBeforePause)")
super.onPause()
}
override fun onResume() {
super.onResume()
if (wasListeningBeforePause) {
isListening = true
}
if (wasObservingBeforePause) {
startVolumeObserver(null)
}
Log.d(TAG, "onResume: restored volume interception (listening=$isListening, observing=$isObservingVolume)")
}
override fun onDestroy() {
stopVolumeObserver()
super.onDestroy()
}
}
@@ -0,0 +1,87 @@
package com.musicassistant.music_assistant
import android.util.Log
import android.view.KeyEvent
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
// while also intercepting volume button events
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
private var methodChannel: MethodChannel? = null
private var isListening = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
methodChannel?.setMethodCallHandler { call, result ->
Log.d(TAG, "Received method call: ${call.method}")
when (call.method) {
"startListening" -> {
isListening = true
Log.d(TAG, "Volume listening ENABLED")
result.success(null)
}
"stopListening" -> {
isListening = false
Log.d(TAG, "Volume listening DISABLED")
result.success(null)
}
else -> {
Log.d(TAG, "Unknown method: ${call.method}")
result.notImplemented()
}
}
}
}
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
// and may consume events before they reach onKeyDown
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
if (event == null) {
return super.dispatchKeyEvent(event)
}
val keyCode = event.keyCode
val action = event.action
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
if (action != KeyEvent.ACTION_DOWN) {
// For volume keys when listening, also consume ACTION_UP to fully block system volume
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
Log.d(TAG, "Consuming ACTION_UP for volume key")
return true
}
return super.dispatchKeyEvent(event)
}
if (!isListening) {
Log.d(TAG, "Not listening, passing to super")
return super.dispatchKeyEvent(event)
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeUp", null)
true // Consume the event to prevent system volume change
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
methodChannel?.invokeMethod("volumeDown", null)
true // Consume the event to prevent system volume change
}
else -> {
Log.d(TAG, "Other key, passing to super")
super.dispatchKeyEvent(event)
}
}
}
}
@@ -1,9 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
@@ -1,11 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+7 -1
View File
@@ -2,11 +2,14 @@ buildscript {
ext.kotlin_version = '2.1.0' ext.kotlin_version = '2.1.0'
repositories { repositories {
google() google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.9.1' classpath 'com.android.tools.build:gradle:8.7.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }
@@ -14,6 +17,9 @@ buildscript {
allprojects { allprojects {
repositories { repositories {
google() google()
// Use Maven Central mirrors as fallback for 403 errors
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/google' }
mavenCentral() mavenCentral()
} }
} }
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
+1 -1
View File
@@ -22,7 +22,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.9.1" apply false id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false id "org.jetbrains.kotlin.android" version "2.1.0" apply false
} }
-242
View File
@@ -1,242 +0,0 @@
# Discovery Rows Submenu - Implementation Plan
## Option: Hybrid with Discovery Badge
### Overview
Transform the "Discovery Mixes" toggle into a section that:
1. Shows individual discovery row toggles
2. Adds those rows to the main home row list with visual distinction
3. Allows full reordering alongside other rows
---
## UI Design
### Settings Screen - Discovery Section
```
┌─────────────────────────────────────────────┐
│ ⚬ Discovery Mixes ● │
│ Show discovery-based rows on home screen │
│ ──────────────────────────────────────── │
│ Discovery rows from Music Assistant: │
│ │
│ ☑ New Releases ● │
│ ☑ Recently Played ● │
│ ☐ Recommended Albums ○ │
│ ☐ For You ○ │
│ │
│ [Refresh available rows] │
└─────────────────────────────────────────────┘
```
### Main Home Row List - With Badge
Discovery rows appear interleaved with a badge:
```
┌─────────────────────────────────────────────┐
│ ⋮ ☑ Recent Albums ● │
│ ⋮ ☑ Discovery: New Releases ● [MUSIC] │ ← Badge
│ ⋮ ☑ Discover Artists ● │
│ ⋮ ☐ Discovery: For You ○ [MUSIC] │
│ ⋮ ☑ Favorite Albums ● │
└─────────────────────────────────────────────┘
```
Badge style: Small chip/icon indicating this is a dynamic discovery row.
---
## Technical Changes
### 1. Settings Screen (`lib/screens/settings_screen.dart`)
**Add state for discovery folders:**
```dart
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {}; // itemId -> enabled
bool _isDiscoveryFoldersExpanded = false;
```
**New methods:**
```dart
Future<void> _loadDiscoveryFolders() async { }
Future<void> _refreshDiscoveryFolders() async { }
void _toggleDiscoveryRow(String itemId, bool enabled) { }
void _toggleDiscoveryMixes(bool enabled) { }
Widget _buildDiscoverySection() { }
```
**Modify existing:**
- `_loadSettings()` - load discovery row preferences
- Home row list builder - filter out individual discovery rows
- Add discovery section before home row list
### 2. Settings Service (`lib/services/settings_service.dart`)
**New methods:**
```dart
static Future<bool> getShowDiscoveryFolders() async { }
static Future<void> setShowDiscoveryFolders(bool value) async { }
// Per-row discovery preferences
static Future<Map<String, bool>> getDiscoveryRowPreferences() async { }
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async { }
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async { }
```
**Storage key pattern:**
- `discovery_folders_enabled` (bool)
- `discovery_row_{itemId}` (bool per row)
### 3. Provider (`lib/providers/music_assistant_provider.dart`)
**Already has:** `getDiscoveryFoldersWithCache()`
**Add method:**
```dart
Future<void> refreshDiscoveryFolders() async {
// Clear cache and refetch from API
_cacheService.clearDiscoveryFoldersCache();
await getDiscoveryFoldersWithCache();
}
```
### 4. Home Screen (`lib/screens/new_home_screen.dart`)
**Changes to row rendering:**
Instead of a single `_discoveryFolders` list, discovery rows are integrated into `_homeRowOrder`:
```dart
// Before: static list of row IDs
List<String> _homeRowOrder = ['recent-albums', 'discover-artists', ...];
// After: can include discovery row IDs
List<String> _homeRowOrder = [
'recent-albums',
'discovery:abc123', // Discovery row ID
'discover-artists',
'discovery:def456',
...
];
```
**New method:**
```dart
bool _isDiscoveryRowId(String rowId) {
return rowId.startsWith('discovery:');
}
String _getDiscoveryRowItemId(String rowId) {
return rowId.substring('discovery:'.length);
}
```
**Modify row rendering:**
- Check if rowId is a discovery row
- If yes, render DiscoveryRow widget with that folder
- Cache discovery folders for quick lookup
### 5. Localization (`lib/l10n/app_en.arb`)
**Add strings:**
```json
{
"discoveryMixesDescription": "Show personalized recommendations from Music Assistant",
"discoveryRows": "Discovery rows from Music Assistant",
"refreshDiscoveryRows": "Refresh available rows",
"noDiscoveryRows": "No discovery rows available",
"discoveryRowBadge": "Music Assistant"
}
```
### 6. Models (`lib/models/recommendation_folder.dart`)
**Add property:**
```dart
class RecommendationFolder {
...
/// Unique ID for use in row ordering and settings
String get rowId => 'discovery:$itemId';
}
```
---
## Data Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ Settings Screen │
├─────────────────────────────────────────────────────────────────┤
│ 1. Load discovery folders from Provider │
│ 2. Load per-row enabled state from Settings │
│ 3. Render section with toggles │
│ 4. User toggles row → save to Settings │
│ 5. User toggles main → save to Settings + update home row list │
│ 6. User refreshes → Provider.fetch → update UI │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Home Screen │
├─────────────────────────────────────────────────────────────────┤
│ 1. Load home row order from Settings │
│ 2. Identify discovery row IDs (prefix "discovery:") │
│ 3. For discovery rows: look up folder from Provider cache │
│ 4. Render appropriate widget (DiscoveryRow or other) │
│ 5. Handle reordering including discovery rows │
└─────────────────────────────────────────────────────────────────┘
```
---
## Migration Path
### Phase 1: Settings Screen (No breaking changes)
- Add discovery section to settings
- Load and display discovery folders
- Individual row toggles (but don't affect home screen yet)
- Refresh button
### Phase 2: Home Screen Integration
- Modify home row order to support discovery row IDs
- Render discovery rows in main list
- Add badge visual distinction
### Phase 3: Polish
- Translations
- Edge cases (folder disappears from MA)
- Performance (lazy loading)
---
## Edge Cases & Considerations
| Issue | Solution |
|-------|----------|
| Discovery folder no longer returned by MA | Keep user preference, hide row if not found |
| User reorders then MA returns new folders | New folders appended to end of discovery section |
| All discovery rows disabled | Discovery section shows but appears empty/minimized |
| Many discovery folders (10+) | Show scrollable sublist, keep main setting compact |
| Row ID collision | Use `discovery:{itemId}` prefix ensures uniqueness |
---
## Implementation Checklist
- [ ] Settings: Add `_discoveryFolders` state
- [ ] Settings: Add `_discoveryRowEnabled` map
- [ ] Settings: Implement `_loadDiscoveryFolders()`
- [ ] Settings: Implement `_buildDiscoverySection()`
- [ ] Settings: Add refresh button handler
- [ ] SettingsService: Add discovery row preference methods
- [ ] Provider: Add `refreshDiscoveryFolders()`
- [ ] HomeScreen: Support discovery row IDs in order list
- [ ] HomeScreen: Render discovery rows with badge
- [ ] Localization: Add new strings
- [ ] Test: Toggle discovery rows on/off
- [ ] Test: Reorder discovery rows
- [ ] Test: Refresh discovery folders
- [ ] Test: Migrate existing preference to new system
@@ -1,328 +0,0 @@
# Remote Connection - Future Implementation Guide
## Overview
This document provides guidance for implementing remote connection to Music Assistant servers via WebRTC. Remote connection allows the Ensemble app to connect to MA servers outside the local network using Nabu Casa's WebRTC signaling infrastructure.
## Current Status: On Hold
Remote connection was partially implemented but put on hold due to a critical bug where the `ma-api` data channel becomes one-way after 1-3 minutes. See `MA_WEBRTC_BUG_REPORT.md` for details.
---
## Architecture
### How It Works
```
┌─────────────────┐ WebSocket ┌──────────────────────────┐
│ Ensemble App │◄──────────────────►│ MA Server (local WS) │
└─────────────────┘ └──────────────────────────┘
│ │
│ (when local unavailable) │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ SignalingClient│◄──── WebSocket ───►│ signaling.music-assistant│
└─────────────────┘ │ .io/ws │
│ └──────────────────────────┘
│ ICE candidates, SDP │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ WebRTC Peer │◄═══ Data Channel ══►│ MA Server WebRTC │
│ Connection │ (ma-api) │ Endpoint │
└─────────────────┘ └──────────────────────────┘
```
### Components
1. **SignalingClient** - WebSocket connection to `wss://signaling.music-assistant.io/ws`
2. **WebRTCConnection** - Manages RTCPeerConnection and data channels
3. **RemoteBridge** - Bridges local WebSocket API to remote WebRTC connection
### Data Channels
| Channel | Purpose | Traffic Pattern |
|---------|---------|-----------------|
| `ma-api` | JSON API requests/responses | High volume, large payloads |
| `sendspin` | Audio streaming + control | Low JSON, binary audio data |
---
## Key Files to Create/Modify
### New Files
| File | Purpose |
|------|---------|
| `lib/services/remote/signaling_client.dart` | WebSocket client for signaling server |
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection management |
| `lib/services/remote/remote_bridge.dart` | Bridges API calls to WebRTC |
### Files to Modify
| File | Changes |
|------|---------|
| `lib/services/music_assistant_api.dart` | Add remote connection fallback |
| `lib/providers/music_assistant_provider.dart` | Expose remote connection state |
| `pubspec.yaml` | Add `flutter_webrtc` dependency |
---
## Implementation Steps
### Step 1: Add Dependencies
```yaml
# pubspec.yaml
dependencies:
flutter_webrtc: ^0.12.0 # or latest
```
### Step 2: Implement SignalingClient
The signaling client connects to Music Assistant's signaling server and exchanges WebRTC offers/answers.
```dart
class SignalingClient {
WebSocket? _socket;
Future<void> connect(String serverId) async {
_socket = await WebSocket.connect('wss://signaling.music-assistant.io/ws');
// Send connect request
_send({
'type': 'connect-request',
'server_id': serverId,
});
// Listen for messages
_socket!.listen(_handleMessage);
}
void _handleMessage(dynamic data) {
final message = jsonDecode(data);
switch (message['type']) {
case 'answer':
onAnswer?.call(message['sdp']);
break;
case 'ice-candidate':
onIceCandidate?.call(message['candidate']);
break;
}
}
}
```
### Step 3: Implement WebRTCConnection
```dart
class WebRTCConnection {
RTCPeerConnection? _peerConnection;
RTCDataChannel? _apiChannel;
RTCDataChannel? _sendspinChannel;
Future<void> connect(SignalingClient signaling) async {
// Create peer connection with STUN/TURN servers
_peerConnection = await createPeerConnection({
'iceServers': [
{'urls': 'stun:stun.l.google.com:19302'},
// TURN servers provided by signaling
],
});
// Create data channels BEFORE creating offer
_apiChannel = await _peerConnection!.createDataChannel(
'ma-api',
RTCDataChannelInit()..ordered = true,
);
_sendspinChannel = await _peerConnection!.createDataChannel(
'sendspin',
RTCDataChannelInit()..ordered = true,
);
// Create and send offer
final offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
signaling.sendOffer(offer.sdp!);
// Handle answer from server
signaling.onAnswer = (sdp) async {
await _peerConnection!.setRemoteDescription(
RTCSessionDescription(sdp, 'answer'),
);
};
}
}
```
### Step 4: Implement RemoteBridge
The bridge translates between the local WebSocket API format and WebRTC data channel messages.
```dart
class RemoteBridge {
final WebRTCConnection _webrtc;
final StreamController<String> _messageController;
void sendCommand(String json) {
_webrtc.sendToApiChannel(json);
}
Stream<String> get messages => _messageController.stream;
}
```
### Step 5: Integrate with MusicAssistantAPI
```dart
class MusicAssistantAPI {
WebSocket? _localSocket;
RemoteBridge? _remoteBridge;
Future<void> connect(String host, {String? serverId}) async {
try {
// Try local connection first
_localSocket = await WebSocket.connect('ws://$host/ws');
} catch (e) {
// Fall back to remote if serverId provided
if (serverId != null) {
_remoteBridge = RemoteBridge();
await _remoteBridge!.connect(serverId);
}
}
}
}
```
---
## Critical Implementation Details
### 1. Channel Creation Order
The client MUST create data channels BEFORE sending the WebRTC offer. The server expects the client to create:
- `ma-api` - for API traffic
- `sendspin` - for audio streaming (optional)
### 2. Health Monitoring
Implement aggressive health checks because data channels can silently die:
```dart
// Track message counts
int _sentCount = 0;
int _recvCount = 0;
DateTime _lastRecvTime = DateTime.now();
// Health check every 10 seconds
void _healthCheck() {
final timeSinceRecv = DateTime.now().difference(_lastRecvTime);
if (timeSinceRecv.inSeconds > 30) {
// Channel is dead, trigger reconnection
_reconnect();
}
}
```
### 3. Keepalive Messages
Send periodic keepalive on the data channel itself (not just signaling):
```dart
void _sendKeepalive() {
_apiChannel?.send(RTCDataChannelMessage(
jsonEncode({'type': 'ping', 'timestamp': DateTime.now().toIso8601String()}),
));
}
```
### 4. Flow Control
Monitor buffer to prevent overflow:
```dart
void sendMessage(String json) {
if (_apiChannel!.bufferedAmount > 1048576) { // 1MB
// Queue message, don't send
_sendQueue.add(json);
return;
}
_apiChannel!.send(RTCDataChannelMessage(json));
}
```
---
## Known Issues & Pitfalls
### 1. One-Way Channel Death (CRITICAL - UNSOLVED)
The `ma-api` channel stops receiving after 1-3 minutes while `sendspin` continues working. This occurs in both flutter_webrtc and WebView implementations.
**Workaround options:**
- Detect via health check and force reconnection
- Try single-channel multiplexing (see `PLAN_B_SINGLE_CHANNEL.md`)
- Report to MA team and wait for server-side fix
### 2. flutter_webrtc Quirks
- `RTCDataChannel.state` may show "open" when actually dead
- `bufferedAmount` accuracy varies by platform
- `onBufferedAmountLow` callback support inconsistent
### 3. Android Background Throttling
Android aggressively throttles background WebRTC. Consider:
- Foreground service for sustained connections
- Reconnection on app resume
### 4. ICE/TURN Issues
- aiortc (server-side) has known TURN + DataChannel bugs
- May need ICE restart on network changes
---
## Testing Checklist
- [ ] Connect via remote when local unavailable
- [ ] Verify API commands work (players/all, library queries)
- [ ] Test sustained connection (30+ minutes)
- [ ] Test app backgrounding and resume
- [ ] Test network transitions (WiFi ↔ cellular)
- [ ] Verify reconnection after channel death
- [ ] Test sendspin audio streaming (if implemented)
---
## Useful Resources
- [Music Assistant Remote Access Docs](https://music-assistant.io/docs/remote-access/)
- [flutter_webrtc Package](https://pub.dev/packages/flutter_webrtc)
- [WebRTC Data Channel MDN](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel)
- Signaling server: `wss://signaling.music-assistant.io/ws`
---
## Files in This Directory
| File | Description |
|------|-------------|
| `FUTURE_IMPLEMENTATION_GUIDE.md` | This file - implementation guide |
| `MA_WEBRTC_BUG_REPORT.md` | Bug report for MA team |
| `WEBRTC_DISCONNECT_FIXES.md` | All fixes attempted |
| `WEBRTC_PROGRESS.md` | Progress log |
| `PLAN_B_SINGLE_CHANNEL.md` | Alternative single-channel approach |
| `webrtc-failure-logs.txt` | Raw failure logs |
| `console-export-*.log` | Browser console exports |
---
## Quick Start for Future Implementation
1. Read this guide fully
2. Review `WEBRTC_DISCONNECT_FIXES.md` for all attempted fixes
3. Check if MA team has fixed server-side issues
4. Start with basic implementation, add health monitoring early
5. Test extensively before shipping - the failure mode is subtle
@@ -1,127 +0,0 @@
# WebRTC Data Channel Failure on Android - Bug Report
## Summary
When connecting to Music Assistant via WebRTC remote access (Nabu Casa), the `ma-api` data channel becomes **one-way** (can send, cannot receive) after 1-3 minutes of use, while the `sendspin` channel continues working normally.
## Environment
- **MA Server**: 2.8.0b7 (Home Assistant add-on)
- **Client**: Android app (Flutter) connecting via Nabu Casa remote access
- **Remote Access**: WebRTC via `wss://signaling.music-assistant.io/ws`
## Reproduction Steps
1. Connect to MA server via WebRTC remote access (not local network)
2. Use the app normally - browse library, view players
3. After 1-3 minutes, the connection degrades
4. Commands time out, but signaling ping/pong continues working
## Observed Behavior
### Timeline from logs:
```
07:14:20 - Working normally
Health: API sent:51 recv:54 (balanced)
07:15:57 - Receive stops
Health: API sent:97 recv:97, lastRecv:12s ago
07:16:02 - Timeouts begin
"TimeoutException: Command timeout: players/all"
07:16:17 - Sendspin still works!
"Sendspin: Received server/time response"
Health: API sent:114 recv:97, lastRecv:27s ago
07:17:30 - Forced reconnect
Health: API sent:137 recv:97, lastRecv:42s ago
"Health check detected stale connection"
```
### Key Observation
| Channel | Traffic Pattern | Status |
|---------|-----------------|--------|
| `ma-api` | High (10+ msg/s, large payloads) | **FAILS** - stops receiving |
| `sendspin` | Low (~1 msg/5s, small payloads) | **SURVIVES** - continues working |
| Signaling | Ping/pong every 15s | **SURVIVES** - continues working |
The WebRTC peer connection remains in "connected" state. ICE connection shows "completed". Only the ma-api data channel stops receiving.
## Testing Performed
### Test 1: flutter_webrtc (Native WebRTC)
- **Result**: ma-api channel dies after 1-3 minutes
- **Sendspin**: Continues working
### Test 2: WebView-based WebRTC (Browser engine)
- **Hypothesis**: Use Android WebView's browser-native WebRTC, since MA Web UI works
- **Implementation**: Hidden InAppWebView running JavaScript WebRTC engine
- **Result**: **Same failure mode** - ma-api dies, sendspin survives
- **Conclusion**: Not a flutter_webrtc bug
### Test 3: Various Data Channel Settings
- Tried `ordered: false` with `maxRetransmits: 3`
- Tried letting server create channels (failed - server expects client to create)
- No improvement
## Analysis
The identical failure in both native and WebView WebRTC implementations suggests the issue is:
1. **Server-side** - Different handling of ma-api vs sendspin channels
2. **TURN relay** - High-traffic channel may hit limits
3. **Not client-specific** - Both implementations fail identically
### Questions for MA Team
1. Are the two data channels (`ma-api`, `sendspin`) created with different settings?
2. Is there any server-side buffering or rate limiting that could affect high-traffic channels?
3. Does the MA Web UI use different data channel configurations?
4. Are there known issues with the TURN relay for sustained high-traffic channels?
## Comparison: MA Web UI vs Android App
| Aspect | MA Web UI | Android App |
|--------|-----------|-------------|
| Platform | Browser tab (visible, active) | Background-capable app |
| WebRTC | Browser native | flutter_webrtc / WebView |
| Result | Works | Fails after 1-3 min |
The MA Web UI runs in a **visible, active browser tab**. Android may throttle background apps differently, but this doesn't explain why sendspin survives while ma-api dies on the same connection.
## Logs
### Health Check Pattern (shows channel death)
```
07:14:25 API sent:51 recv:54, lastRecv:2s ago <- Working
07:15:35 API sent:87 recv:91, lastRecv:2s ago <- Working
07:16:05 API sent:108 recv:97, lastRecv:17s ago <- DYING (gap growing)
07:16:15 API sent:114 recv:97, lastRecv:27s ago <- DEAD
07:16:45 API sent:130 recv:97, lastRecv:27s ago <- Still sending, no receive
07:17:30 API sent:137 recv:97, lastRecv:42s ago <- Forced reconnect
```
### Sendspin Still Working During Failure
```
07:16:17 Sendspin: Received server/time response <- Still alive!
07:16:47 Sendspin: Received server/time response <- Still alive!
```
## Requested Information
1. Data channel creation code/settings for both channels
2. Any server-side differences in channel handling
3. TURN server configuration details
4. Whether this has been reported by other Android/mobile clients
## Workaround
Currently using local WebSocket connection when on same network. Remote access via WebRTC is unusable for sustained use.
---
**Reporter**: Ensemble Android App Developer
**Date**: 2026-01-24
@@ -1,149 +0,0 @@
# Plan B: Single Data Channel Multiplexing
## Why This Might Be Needed
If upgrading flutter_webrtc doesn't fix the one-way channel failure, the root cause is likely a bug in how flutter_webrtc (or underlying libwebrtc) handles **multiple data channels**.
Evidence:
- MA Web UI works fine with local playback (same server, browser WebRTC)
- Our app uses flutter_webrtc which has known issues:
- [Issue #1428](https://github.com/flutter-webrtc/flutter-webrtc/issues/1428): Threading issues causing data loss
- [Issue #974](https://github.com/flutter-webrtc/flutter-webrtc/issues/974): EventChannel intermittently fails
- Pattern matches: second channel survives while first dies
## Current Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├────────────────────┬────────────────────────────┤
│ ma-api channel │ sendspin channel │
│ (JSON API) │ (JSON + binary audio) │
│ SCTP stream 0 │ SCTP stream 1 │
└────────────────────┴────────────────────────────┘
```
## Proposed Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├─────────────────────────────────────────────────┤
│ unified channel │
│ (multiplexed: API + Sendspin text + binary) │
│ SCTP stream 0 │
└─────────────────────────────────────────────────┘
```
## Multiplexing Protocol
### Message Framing
Each message has a 1-byte type prefix:
| Prefix | Type | Content |
|--------|------|---------|
| 0x00 | API | JSON text (MA API request/response) |
| 0x01 | Sendspin Text | JSON text (Sendspin control messages) |
| 0x02 | Sendspin Binary | Raw bytes (PCM audio data) |
### Implementation
#### Sending (Client → Server)
```dart
void sendApiMessage(String json) {
final prefix = Uint8List.fromList([0x00]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x00;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinText(String json) {
final prefix = Uint8List.fromList([0x01]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x01;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinBinary(Uint8List audio) {
final message = Uint8List(1 + audio.length);
message[0] = 0x02;
message.setRange(1, message.length, audio);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
```
#### Receiving (Server → Client)
```dart
void _onMessage(RTCDataChannelMessage message) {
final data = message.binary;
if (data == null || data.isEmpty) return;
final type = data[0];
final payload = data.sublist(1);
switch (type) {
case 0x00: // API
final json = utf8.decode(payload);
_handleApiMessage(json);
break;
case 0x01: // Sendspin text
final json = utf8.decode(payload);
_handleSendspinText(json);
break;
case 0x02: // Sendspin binary
_handleSendspinBinary(payload);
break;
}
}
```
## Server-Side Changes Required
The Music Assistant server would need to:
1. Accept a single "unified" data channel instead of separate ma-api and sendspin channels
2. Multiplex responses using the same framing protocol
3. This requires changes to the MA remote connection code
**Alternative**: Keep server unchanged, do multiplexing in a proxy layer on the client.
## Client-Only Approach (No Server Changes)
If we can't change the server, we could:
1. Still create two channels to the server (ma-api + sendspin)
2. But internally route them through a single "virtual" channel for flutter_webrtc
3. This is more complex and may not avoid the underlying bug
## Files to Modify
| File | Changes |
|------|---------|
| `webrtc_connection.dart` | Single channel creation, multiplexing logic |
| `remote_bridge.dart` | Route messages based on type prefix |
## Risks
1. **Server compatibility**: Requires MA server changes or complex client proxy
2. **Performance**: Extra byte per message, decode overhead
3. **Binary handling**: Must handle all messages as binary (not text)
## Testing Plan
1. Implement client-side changes
2. Test with modified server (if available) or mock
3. Verify both API and Sendspin traffic flows correctly
4. Long-duration stability test
## Decision Point
Try this approach if:
- flutter_webrtc 0.14.x doesn't fix the issue
- The one-way failure pattern continues
- We've confirmed the issue is multi-channel specific
@@ -1,406 +0,0 @@
# WebRTC Data Channel Disconnect Fixes
## Document Purpose
Track all fixes attempted for the WebRTC data channel disconnection issues in Ensemble's remote connection feature. This document should be referenced when debugging future disconnection problems.
---
## Problem Summary
The WebRTC data channel between the Ensemble mobile app and Music Assistant server can fail in several ways:
1. **One-way failure**: Data channel appears "open" but only sends work, receives stop
2. **Silent disconnect**: No error events fire, channel just stops working
3. **ICE failure**: ICE connection fails, properly detected but causes disconnect
4. **Signaling disconnect**: Signaling WebSocket closes, causing ICE/WebRTC to fail
---
## Key Files
| File | Purpose |
|------|---------|
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection, data channel management |
| `lib/services/remote/remote_bridge.dart` | WebSocket bridge, health monitoring, reconnection logic |
| `lib/services/remote/signaling_client.dart` | Signaling server WebSocket connection |
---
## Implemented Fixes (Chronological)
### Fix 1: Data Channel Keepalive (Original)
**Problem**: Data channel could silently die without any error events firing.
**Solution**: Added periodic keepalive ping/pong on the data channel itself.
**Code** (`webrtc_connection.dart`):
- `_keepaliveTimer`: Runs every 10 seconds
- `_checkKeepalive()`: Checks time since last data received
- `_sendKeepalivePing()`: Sends JSON ping message
- `_recordDataReceived()`: Called on every message receipt
**Constants**:
```dart
static const _keepaliveInterval = Duration(seconds: 10);
static const _keepaliveTimeout = Duration(seconds: 20); // Reduced from 30
static const _maxKeepaliveMisses = 2; // Reduced from 3
```
### Fix 2: ICE Recovery
**Problem**: ICE connection could temporarily disconnect and might auto-recover.
**Solution**: Added ICE recovery timer before declaring failure.
**Code** (`webrtc_connection.dart`):
- `_startIceRecovery()`: Starts 5-second timer when ICE disconnects
- `_cancelIceRecovery()`: Cancels if ICE recovers on its own
- `_attemptIceRestart()`: Calls `restartIce()` if recovery timer expires
### Fix 3: Flow Control (Buffer Overflow Prevention)
**Problem**: Fire-and-forget sends could overflow the 16MB SCTP buffer, causing silent channel death.
**Solution**: Added send-side flow control with buffering.
**Code** (`webrtc_connection.dart`):
```dart
static const _maxBufferedAmount = 1048576; // 1MB threshold
static const _bufferLowThreshold = 65536; // 64KB resume threshold
bool _sendPaused = false;
final _sendQueue = <String>[];
```
**Modified `sendRaw()`**:
- Checks `bufferedAmount` before sending
- Queues messages if buffer > 1MB
- `onBufferedAmountLow` callback drains queue
### Fix 4: Health Check Stale Detection
**Problem**: Health check could see "30s since last data" but not trigger reconnection.
**Solution**: Reduced stale detection threshold from 45s to 30s.
**Code** (`remote_bridge.dart`):
```dart
if (_state == RemoteBridgeState.connected && timeSinceLastData > 30 && !_isReconnecting) {
// Force reconnection
}
```
### Fix 5: Reduced Keepalive Timeouts
**Problem**: 30s timeout × 3 misses = 90s minimum before detection.
**Solution**: Reduced to 20s timeout × 2 misses = ~40s detection.
### Fix 6: Recv Stagnation Detection
**Problem**: One-way failure where recv stops but sent continues not detected quickly enough.
**Solution**: Track recv count across health checks. If recv stuck while sent increases for 2 consecutive checks (20s), force reconnection.
**Code** (`remote_bridge.dart`):
```dart
// Track across health checks
int _lastHealthCheckRecvCount = 0;
int _lastHealthCheckSentCount = 0;
int _recvStagnationCount = 0;
// In health check:
if (recvStuck && sentIncreased) {
_recvStagnationCount++;
if (_recvStagnationCount >= 2) {
// Force reconnection - channel is one-way
}
}
```
**Detection time**: ~20 seconds (2 × 10s health check interval)
---
## Log Patterns to Watch For
### Healthy Connection
```
Health check - API sent:93 recv:88 ... lastRecv:0s ago
```
- `sent` and `recv` both increasing
- `lastRecv` low (< 5s typically)
### One-Way Failure (CRITICAL)
```
Health check - API sent:206 recv:163 ... lastRecv:30s ago
Health check - API sent:218 recv:163 ... lastRecv:40s ago
```
- `sent` increases, `recv` STUCK
- `lastRecv` keeps increasing
- Data channel still shows "open"
### Signaling Disconnect
```
WebRTC: Signaling disconnected
... (6-16 seconds later) ...
WebRTC: ICE connection state: RTCIceConnectionStateDisconnected
WebRTC: ICE connection state: RTCIceConnectionStateFailed
```
### Network Failure (DNS)
```
Signaling: WebSocket closed
... (reconnection attempt) ...
Signaling: Failed to connect: SocketException: Failed host lookup: 'signaling.music-assistant.io'
```
This indicates the phone lost network connectivity entirely.
### Reconnection Storm (Signaling Instability)
```
Signaling: Connecting to wss://signaling.music-assistant.io/ws
Signaling: Sending connect-request
Signaling: WebSocket closed ← Closes within 100-500ms
WebRTC: Signaling disconnected
RemoteBridge: Scheduling reconnect attempt X/10 in Ys
```
Rapid reconnection loop - signaling never stays connected long enough.
### Flow Control Engaged
```
WebRTC: ⚠️ Buffer full (1234567 bytes) or paused - queueing message
WebRTC: Buffer drained to 12345 bytes - draining 5 queued messages
WebRTC: ✅ Send queue drained (5 messages) - resuming normal sends
```
### Keepalive Detection
```
WebRTC: ⚠️ Keepalive timeout! No data for 22s (missed: 1/2)
WebRTC: ❌ Data channel appears dead - triggering reconnection
```
---
## Known Issues / Limitations
### 1. flutter_webrtc Limitations
- `RTCDataChannel.state` may show "open" when actually dead (Issue #843)
- `bufferedAmount` may not be accurate on all platforms
- `onBufferedAmountLow` callback support varies
### 2. SCTP Receive Window
- If server sends faster than client processes, receive window fills
- This causes server-side to stop sending, client sees no data
- No way to monitor receive-side buffer from client
### 3. TURN Server Issues
- aiortc (server-side) has known issues with TURN + DataChannel (#1156)
- Data channel may fail silently when using TURN relay
### 4. Network Transitions
- WiFi → Cellular or vice versa can cause ICE to fail
- App backgrounding on mobile can close WebSocket/WebRTC
---
## Root Cause Categories
### Category A: Network Issues (Not Code Bugs)
These are environmental issues, not bugs in our code:
- **DNS failure**: `Failed host lookup` - Phone lost internet
- **WiFi/Cellular switch**: Network changed, connections dropped
- **Server unreachable**: Signaling server down or blocked
- **Firewall/NAT issues**: TURN relay needed but unavailable
**Evidence**: Signaling WebSocket closes, DNS lookups fail, reconnection attempts fail repeatedly.
### Category B: WebRTC/SCTP Issues (Partial Code Control)
These can be mitigated but not fully prevented:
- **One-way channel death**: Channel appears open but data stops flowing
- **Buffer overflow**: Too much data sent, SCTP buffer exceeded
- **ICE failure**: Network path lost, no recovery possible
**Evidence**: Data channel shows "open" but `recv` stuck, `lastRecv` increasing.
### Category C: Code Bugs (Fixable)
Issues in our implementation:
- **Detection too slow**: Timeouts too long, misses too many
- **Recovery not triggered**: State checks wrong, reconnection not started
- **Resource leaks**: Timers not cancelled, connections not cleaned up
**Evidence**: Logs show problem but no action taken, or wrong action taken.
---
## Debugging Checklist
When investigating a disconnect:
1. **Find the last successful response**:
```
grep "WebRTC API → WS" logs.txt | head -5
```
2. **Check health check progression**:
```
grep "Health check" logs.txt
```
Look for `recv` getting stuck while `sent` increases.
3. **Check for signaling issues**:
```
grep -i "signaling" logs.txt
```
4. **Check ICE state changes**:
```
grep "ICE connection state" logs.txt
```
5. **Check for keepalive detection**:
```
grep -i "keepalive" logs.txt
```
6. **Check buffer status**:
```
grep -E "(buffer|queue|paused)" logs.txt
```
---
## Potential Future Improvements
### 1. Server-Side Backpressure
MA protocol doesn't have explicit backpressure. Could add:
- Client-side request throttling
- Application-level ACKs for critical messages
### 2. More Aggressive Detection
- Track recv count across health checks, detect stagnation
- Shorter keepalive interval (5s instead of 10s)
### 3. Proactive Health Signals
- Monitor `bufferedAmount` trend (increasing = problem)
- Track round-trip latency of keepalive pings
### 4. Connection Quality Metrics
- Expose packet loss, jitter from WebRTC stats
- Use to predict imminent failure
---
## Test Scenarios
| Scenario | How to Test | Expected Behavior |
|----------|-------------|-------------------|
| Normal usage | Browse library, play music | No warnings |
| Heavy sync | Pull-to-refresh library | Buffer may queue, should drain |
| Long session | Leave connected 2+ hours | Should stay connected |
| Rapid navigation | Quick tab switching | Brief queue usage, recovers |
| Network change | WiFi → Cellular | Should detect and reconnect |
| App background | Background for 5+ min | May disconnect, should reconnect |
---
## Observed Failure Patterns
### Pattern 1: One-Way Death After Library Sync
**Observed**: 2026-01-23 (multiple times)
```
17:13:06.175 - Last response (playlists sync)
17:13:06.264 - Local batch save
-- 5 seconds silence --
17:13:11.005 - Send players/all → NO RESPONSE
17:13:13.914 - ICE state: completed (looks healthy!)
17:13:28 - Keepalive detects timeout
17:13:38 - Reconnection triggered
```
**Characteristics**:
- Often happens after library sync (large batch responses)
- ICE shows "completed" even though data channel is dead
- Signaling ping/pong continues working
- Only data channel receive is affected
**Hypothesis**:
- Server-side SCTP buffer exhausted from sending large responses?
- Client receive window full, server blocks?
- flutter_webrtc onMessage callback stops firing?
### Pattern 2: Reconnection Storm / Signaling Instability
**Observed**: 2026-01-23 17:30-17:31
```
17:31:05.093 - Signaling: Connecting
17:31:05.216 - Signaling: Sending connect-request
17:31:05.218 - Signaling: WebSocket closed (123ms later!)
17:31:05.218 - WebRTC: Signaling disconnected
```
**Characteristics**:
- Signaling WebSocket closes almost immediately after connect
- Multiple rapid reconnection attempts (storm)
- Connection never stable long enough for data flow
- Often happens after network instability
- Artist detail, albums etc don't load because connection keeps failing
**Possible Causes**:
- Network instability (WiFi/cellular issues)
- Signaling server rate limiting
- DNS resolution failures
- Server rejecting rapid reconnection attempts
**Side Effects**:
- UI shows errors like "Not connected to Music Assistant server"
- Artist/album detail screens fail to load
- Players list empty or stale
### Pattern 3: API Channel Dies While Sendspin Lives
**Observed**: 2026-01-23 17:53
```
17:53:35.755 - Send album_tracks request → NO RESPONSE
17:53:40.596 - Health check: recv stuck at 197, sent increased
17:53:55.204 - Sendspin channel RECEIVES server/time response!
17:54:02.790 - Keepalive detects dead API channel
```
**Characteristics**:
- API data channel (ma-api) stops receiving
- Sendspin data channel continues working normally
- Same WebRTC peer connection, different data channels
- This proves it's NOT a network issue - Sendspin traffic flows fine
**Hypothesis**:
- flutter_webrtc bug where `onMessage` callback stops firing for one channel
- Possible memory/GC issue with the callback
- Server-side might be sending to wrong channel?
**Key Evidence**:
- Recv count stays at 197 for API
- Sendspin recv count increases (7→8)
- Both channels use same peer connection
---
## Version History
| Date | Build | Changes |
|------|-------|---------|
| 2026-01-23 | ~v49 | Initial flow control implementation |
| 2026-01-23 | ~v49 | Reduced keepalive timeout 30→20s, misses 3→2 |
| 2026-01-23 | ~v49 | Reduced health check stale threshold 45→30s |
| 2026-01-23 | ~v49 | Fixed excessive "queue drained" logging |
| 2026-01-23 | ~v49 | Created WEBRTC_DISCONNECT_FIXES.md documentation |
| 2026-01-23 | ~v49 | Added recv stagnation detection (20s detection) |
| 2026-01-23 | ~v49 | Documented Pattern 2: Reconnection storm / signaling instability |
| 2026-01-23 | ~v49 | Fixed race condition in _scheduleReconnect (multiple simultaneous calls) |
| 2026-01-23 | ~v49 | Added WebSocket close code/reason logging to signaling |
| 2026-01-23 | ~v49 | **CRITICAL FIX**: _reconnectWebRTC() was returning immediately due to _isReconnecting already being true |
| 2026-01-23 | ~v49 | Fixed old WebRTC callbacks firing during reconnection cleanup (clear callbacks before disconnect) |
| 2026-01-23 | ~v49 | Fixed onStateChanged handler calling _handleWebRTCDisconnection during active reconnection |
---
## References
- flutter_webrtc Issue #843: Data channel state unreliable
- aiortc Issue #1156: TURN + DataChannel bugs
- WebRTC SCTP: Max buffer is 16MB, recommended threshold 1MB
- Music Assistant remote protocol: No explicit flow control
-118
View File
@@ -1,118 +0,0 @@
# WebRTC Remote Connection Progress
## Problem Statement
The flutter_webrtc library has reliability issues causing data channel failures during remote connections via Nabu Casa. The ma-api data channel becomes one-way (can send, but receives stop) after some time, while the sendspin channel continues working.
## Approaches Tried
### 1. Original Implementation (flutter_webrtc)
- **Status**: Fails
- **Issue**: ma-api data channel goes one-way after some time
- **Files**: `lib/services/remote/webrtc_connection.dart`
### 2. WebView-based WebRTC Implementation
- **Status**: Same failure mode
- **Hypothesis**: Use browser-native WebRTC via InAppWebView, since MA Web UI works
- **Result**: Same one-way channel death occurs
- **Key Finding**: This proves the issue is NOT specific to flutter_webrtc
## Key Observations
From the logs:
```
lastRecv:26s ago <- No messages received in 26 seconds
API sent:674 recv:618 <- Gap growing, responses stopped
```
- Signaling ping/pong continues working
- Sendspin channel continues receiving (`server/time` responses)
- Only ma-api channel becomes one-way
- WebRTC peer connection itself remains "connected"
## Differences from Working MA Web UI
The MA Web UI runs in a **visible, active browser tab**. Our implementations:
1. flutter_webrtc: Native WebRTC in background app
2. WebView: Browser WebRTC in **hidden 1x1 pixel WebView**
Android aggressively throttles hidden WebViews to save battery.
## Current Implementation (WebView)
### Files Created
| File | Purpose |
|------|---------|
| `lib/services/remote/webview_webrtc_connection.dart` | Dart WebView wrapper |
| `assets/webrtc_engine.js` | JavaScript WebRTC engine |
### Files Modified
| File | Changes |
|------|---------|
| `pubspec.yaml` | Added `flutter_inappwebview: ^6.0.0` |
| `lib/services/remote/remote_bridge.dart` | Uses `WebViewWebRTCConnection` |
| `lib/providers/music_assistant_provider.dart` | Exposes WebView widget |
| `lib/main.dart` | Adds WebView to widget tree |
### Architecture
```
MusicAssistantAPI -> localhost WebSocket -> RemoteBridge
|
WebViewWebRTCConnection
|
Hidden InAppWebView
|
JavaScript WebRTC Engine
|
MA Server via TURN
```
## Current Fixes Being Tested
### 1. Prevent WebView Throttling
- Changed WebView position from off-screen (-10,-10) to on-screen (0,0) with Opacity(0)
- Added various InAppWebViewSettings to prevent background throttling
### 2. Data Channel Settings
- Changed from `ordered: true` to `ordered: false` with `maxRetransmits: 3`
- Hypothesis: Ordered delivery may cause buffer bloat issues
### 3. Buffer Monitoring
- Added `bufferedAmount` checking in JavaScript
- Logs warning when buffer exceeds 1MB
- Added `getChannelHealth()` function for diagnostics
### 4. Reduced Logging
- Removed per-message logging (was filling logs too fast)
- Keep only state changes, errors, and warnings
## Experiments Completed
### 5. Server-Created Channels (Failed)
- **Hypothesis**: Let server create data channels to avoid ID collisions
- **Result**: Connection timeout - server expects CLIENT to create channels
- **Learning**: Client must create channels before sending offer
## Next Steps to Try
1. **Make WebView actually visible** - Android may still throttle Opacity(0) WebView
2. **Single data channel** - Use only ma-api, disable sendspin
3. **Periodic channel recreation** - Detect stale channel and recreate
4. **Report to MA team** - Browser WebRTC also fails, suggesting server-side issue
## Test APK Location
`/home/chris/Ensemble/ensemble-webview-webrtc.apk`
## Useful Debug Commands
Check channel health (in JS console or via Dart):
```javascript
getChannelHealth()
// Returns: { peerConnection, iceConnection, apiChannel, apiBuffered, sendspinChannel, sendspinBuffered, signalingConnected }
```
## Open Questions
1. Why does sendspin channel survive when ma-api dies?
2. Is there a difference in how MA server handles the two channels?
3. Does MA Web UI use different data channel settings?
4. Is there a TURN relay issue specific to high-traffic channels?
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
New package ID (com.collotsspot.ensemble) - reinstall required.
Prepares for F-Droid and Play Store submission.
@@ -1 +0,0 @@
Add fastlane metadata for F-Droid.
@@ -1 +0,0 @@
Disable dependency metadata in APK for F-Droid compliance.
@@ -1,13 +0,0 @@
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
Features:
* Stream music directly to your phone with background playback
* Control any speaker or device connected to Music Assistant
* Multi-room grouping capabilities
* Queue management with drag-to-reorder
* Background playback with media notifications
* Fast ~300ms response for play/pause controls
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

@@ -1 +0,0 @@
Unofficial mobile client for Music Assistant
@@ -1 +0,0 @@
Ensemble
-6
View File
@@ -32,12 +32,6 @@ class HeroTags {
/// Podcast title hero tag prefix /// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_'; static const String podcastTitle = 'podcast_title_';
/// Podcast episode cover hero tag prefix
static const String podcastEpisodeCover = 'podcast_episode_cover_';
/// Podcast episode title hero tag prefix
static const String podcastEpisodeTitle = 'podcast_episode_title_';
/// Playlist cover hero tag prefix /// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_'; static const String playlistCover = 'playlist_cover_';
+2 -91
View File
@@ -190,37 +190,12 @@ class SearchHistory extends Table {
DateTimeColumn get searchedAt => dateTime()(); DateTimeColumn get searchedAt => dateTime()();
} }
/// Cached detail tracks for album/playlist detail screens @DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
/// 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 { class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 7; int get schemaVersion => 6;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -259,10 +234,6 @@ class AppDatabase extends _$AppDatabase {
if (from < 6) { if (from < 6) {
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'"); 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);
}
}, },
); );
} }
@@ -725,66 +696,6 @@ class AppDatabase extends _$AppDatabase {
await delete(searchHistory).go(); await delete(searchHistory).go();
} }
// ============================================
// Detail Track Cache Operations
// ============================================
/// Save detail tracks for an album or playlist
Future<void> saveDetailTracks(String parentType, String parentKey, String tracksJson) async {
final cacheKey = '${parentType}_$parentKey';
final now = DateTime.now();
await into(detailTrackCache).insertOnConflictUpdate(
DetailTrackCacheCompanion.insert(
cacheKey: cacheKey,
parentType: parentType,
parentKey: parentKey,
tracksJson: tracksJson,
lastFetched: now,
lastAccessed: now,
),
);
// Evict old entries if cache grows too large
await _evictDetailTrackCache();
}
/// Get cached detail tracks for an album or playlist
/// Returns null if not cached, updates lastAccessed on hit
Future<DetailTrackCacheData?> getDetailTracks(String parentType, String parentKey) async {
final cacheKey = '${parentType}_$parentKey';
final result = await (select(detailTrackCache)
..where((d) => d.cacheKey.equals(cacheKey)))
.getSingleOrNull();
if (result != null) {
// Update last accessed time
await (update(detailTrackCache)..where((d) => d.cacheKey.equals(cacheKey)))
.write(DetailTrackCacheCompanion(lastAccessed: Value(DateTime.now())));
}
return result;
}
/// Evict oldest detail track cache entries when over limit
Future<void> _evictDetailTrackCache() async {
const maxEntries = 100;
final count = await (selectOnly(detailTrackCache)..addColumns([countAll()]))
.map((row) => row.read(countAll())!)
.getSingle();
if (count <= maxEntries) return;
// Delete oldest by lastAccessed
final oldest = await (select(detailTrackCache)
..orderBy([(d) => OrderingTerm.asc(d.lastAccessed)])
..limit(count - maxEntries))
.get();
for (final entry in oldest) {
await (delete(detailTrackCache)..where((d) => d.cacheKey.equals(entry.cacheKey))).go();
}
}
/// Clear all detail track cache
Future<void> clearDetailTrackCache() async {
await delete(detailTrackCache).go();
}
// ============================================ // ============================================
// Batch Cache Operations // Batch Cache Operations
// ============================================ // ============================================
+3 -579
View File
@@ -2916,381 +2916,6 @@ 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 { abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e); _$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this); $AppDatabaseManager get managers => $AppDatabaseManager(this);
@@ -3303,8 +2928,6 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $CachedQueueTable cachedQueue = $CachedQueueTable(this); late final $CachedQueueTable cachedQueue = $CachedQueueTable(this);
late final $HomeRowCacheTable homeRowCache = $HomeRowCacheTable(this); late final $HomeRowCacheTable homeRowCache = $HomeRowCacheTable(this);
late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this); late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this);
late final $DetailTrackCacheTable detailTrackCache =
$DetailTrackCacheTable(this);
late final Index idxRecentlyPlayedProfile = Index( late final Index idxRecentlyPlayedProfile = Index(
'idx_recently_played_profile', 'idx_recently_played_profile',
'CREATE INDEX idx_recently_played_profile ON recently_played (profile_username)'); 'CREATE INDEX idx_recently_played_profile ON recently_played (profile_username)');
@@ -3335,7 +2958,6 @@ abstract class _$AppDatabase extends GeneratedDatabase {
cachedQueue, cachedQueue,
homeRowCache, homeRowCache,
searchHistory, searchHistory,
detailTrackCache,
idxRecentlyPlayedProfile, idxRecentlyPlayedProfile,
idxRecentlyPlayedProfilePlayed, idxRecentlyPlayedProfilePlayed,
idxLibraryCacheType, idxLibraryCacheType,
@@ -3374,8 +2996,7 @@ final class $$ProfilesTableReferences
$$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs { $$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed) final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
.filter((f) => f.profileUsername.username .filter((f) => f.profileUsername.username($_item.username));
.sqlEquals($_itemColumn<String>('username')!));
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db)); final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
return ProcessedTableManager( return ProcessedTableManager(
@@ -3568,8 +3189,7 @@ class $$ProfilesTableTableManager extends RootTableManager<
getPrefetchedDataCallback: (items) async { getPrefetchedDataCallback: (items) async {
return [ return [
if (recentlyPlayedRefs) if (recentlyPlayedRefs)
await $_getPrefetchedData<Profile, $ProfilesTable, await $_getPrefetchedData(
RecentlyPlayedData>(
currentTable: table, currentTable: table,
referencedTable: $$ProfilesTableReferences referencedTable: $$ProfilesTableReferences
._recentlyPlayedRefsTable(db), ._recentlyPlayedRefsTable(db),
@@ -3634,10 +3254,8 @@ final class $$RecentlyPlayedTableReferences extends BaseReferences<
db.recentlyPlayed.profileUsername, db.profiles.username)); db.recentlyPlayed.profileUsername, db.profiles.username));
$$ProfilesTableProcessedTableManager get profileUsername { $$ProfilesTableProcessedTableManager get profileUsername {
final $_column = $_itemColumn<String>('profile_username')!;
final manager = $$ProfilesTableTableManager($_db, $_db.profiles) final manager = $$ProfilesTableTableManager($_db, $_db.profiles)
.filter((f) => f.username.sqlEquals($_column)); .filter((f) => f.username($_item.profileUsername));
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db)); final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
if (item == null) return manager; if (item == null) return manager;
return ProcessedTableManager( return ProcessedTableManager(
@@ -5079,198 +4697,6 @@ typedef $$SearchHistoryTableProcessedTableManager = ProcessedTableManager<
), ),
SearchHistoryData, SearchHistoryData,
PrefetchHooks Function()>; 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 { class $AppDatabaseManager {
final _$AppDatabase _db; final _$AppDatabase _db;
@@ -5293,6 +4719,4 @@ class $AppDatabaseManager {
$$HomeRowCacheTableTableManager(_db, _db.homeRowCache); $$HomeRowCacheTableTableManager(_db, _db.homeRowCache);
$$SearchHistoryTableTableManager get searchHistory => $$SearchHistoryTableTableManager get searchHistory =>
$$SearchHistoryTableTableManager(_db, _db.searchHistory); $$SearchHistoryTableTableManager(_db, _db.searchHistory);
$$DetailTrackCacheTableTableManager get detailTrackCache =>
$$DetailTrackCacheTableTableManager(_db, _db.detailTrackCache);
} }
+1 -11
View File
@@ -295,15 +295,5 @@
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen", "showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen", "pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
"holdToSync": "Gedrückt halten zum Sync", "holdToSync": "Gedrückt halten zum Sync",
"swipeToAdjustVolume": "Wischen für Lautstärke", "swipeToAdjustVolume": "Wischen für Lautstärke"
"aboutAndSupport": "Über & Unterstützung",
"supportDevelopment": "Entwicklung unterstützen",
"ensembleIsUnofficial": "Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.",
"sponsorOnGitHub": "Auf GitHub sponsoren",
"buyMeAKoFi": "Kauf mir einen Ko-fi",
"aboutEnsemble": "Über Ensemble",
"version": "Version",
"visitGitHubRepo": "GitHub-Repository besuchen",
"reportABug": "Einen Fehler melden"
} }
+32 -97
View File
@@ -51,7 +51,7 @@
"description": "Hint for port field" "description": "Hint for port field"
}, },
"portDescription": "Leave blank unless using a custom port", "portDescription": "Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.",
"@portDescription": { "@portDescription": {
"description": "Description for port field" "description": "Description for port field"
}, },
@@ -221,36 +221,6 @@
"description": "Home screen section title" "description": "Home screen section title"
}, },
"discoveryMixes": "Discovery Mixes",
"@discoveryMixes": {
"description": "Provider discovery mixes (e.g., Tidal playlists)"
},
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
"@discoveryMixesDescription": {
"description": "Description for discovery mixes setting"
},
"discoveryRows": "Discovery rows from Music Assistant",
"@discoveryRows": {
"description": "Label for discovery rows section"
},
"refreshDiscoveryRows": "Refresh available rows",
"@refreshDiscoveryRows": {
"description": "Button to refresh discovery rows"
},
"noDiscoveryRows": "No discovery rows available",
"@noDiscoveryRows": {
"description": "Empty state when no discovery rows available"
},
"discoveryRowBadge": "Music Assistant",
"@discoveryRowBadge": {
"description": "Badge label for discovery rows"
},
"continueListening": "Continue Listening", "continueListening": "Continue Listening",
"@continueListening": { "@continueListening": {
"description": "Home screen section title" "description": "Home screen section title"
@@ -634,11 +604,6 @@
"tracksAddedToQueue": "Tracks added to queue", "tracksAddedToQueue": "Tracks added to queue",
"@tracksAddedToQueue": { "@tracksAddedToQueue": {
"description": "Snackbar message for tracks added to queue"
},
"trackAddedToQueue": "Track added to queue",
"@trackAddedToQueue": {
"description": "Confirmation when tracks added" "description": "Confirmation when tracks added"
}, },
@@ -1104,10 +1069,6 @@
"sortAlphabetically": "Sort alphabetically", "sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year", "sortByYear": "Sort by year",
"sortBySeriesOrder": "Sort by series order", "sortBySeriesOrder": "Sort by series order",
"sortByDuration": "Sort by duration",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"viewDetails": "View details",
"listView": "List view", "listView": "List view",
"gridView": "Grid view", "gridView": "Grid view",
"noBooksInSeries": "No books found in this series", "noBooksInSeries": "No books found in this series",
@@ -1126,12 +1087,11 @@
"showFavoritePodcasts": "Show a row of your favorite podcasts", "showFavoritePodcasts": "Show a row of your favorite podcasts",
"extractColorsFromArtwork": "Extract colors from album and artist artwork", "extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen", "chooseHomeScreenRows": "Choose which rows to display on the home screen",
"favorite": "Favorite",
"addedToFavorites": "Added to favorites", "addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites", "removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library", "addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library", "removedFromLibrary": "Removed from library",
"addToLibrary": "Add", "addToLibrary": "Add to library",
"unknown": "Unknown", "unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks", "noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all", "showAll": "Show all",
@@ -1247,21 +1207,11 @@
"description": "Bottom sheet title for adding album to queue" "description": "Bottom sheet title for adding album to queue"
}, },
"addToQueue": "Add to queue",
"@addToQueue": {
"description": "Add item to queue on selected player"
},
"addToQueueOn": "Add to queue on...", "addToQueueOn": "Add to queue on...",
"@addToQueueOn": { "@addToQueueOn": {
"description": "Bottom sheet title for adding to queue" "description": "Bottom sheet title for adding to queue"
}, },
"startRadio": "Start radio",
"@startRadio": {
"description": "Start radio on selected player"
},
"startRadioOn": "Start {name} radio on...", "startRadioOn": "Start {name} radio on...",
"@startRadioOn": { "@startRadioOn": {
"description": "Bottom sheet title for starting radio", "description": "Bottom sheet title for starting radio",
@@ -1407,16 +1357,6 @@
"description": "Description for show hints toggle" "description": "Description for show hints toggle"
}, },
"volumePrecisionMode": "Volume Precision Mode",
"@volumePrecisionMode": {
"description": "Setting for fine volume control"
},
"volumePrecisionModeDescription": "Hold still while adjusting volume for fine control",
"@volumePrecisionModeDescription": {
"description": "Description for volume precision mode setting"
},
"display": "Display", "display": "Display",
"@display": { "@display": {
"description": "Settings section title for display options" "description": "Settings section title for display options"
@@ -1715,58 +1655,53 @@
"description": "Hint for selecting player to add to queue" "description": "Hint for selecting player to add to queue"
}, },
"aboutAndSupport": "About & Support", "localConnection": "Server URL",
"@aboutAndSupport": { "@localConnection": {
"description": "About and support section title" "description": "Direct connection mode toggle label"
}, },
"supportDevelopment": "Support Development", "remoteConnection": "Remote Access ID",
"@supportDevelopment": { "@remoteConnection": {
"description": "Support development section" "description": "HA Remote Access connection mode toggle label"
}, },
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.", "remoteAccessId": "Remote Access ID",
"@ensembleIsUnofficial": { "@remoteAccessId": {
"description": "Description about Ensemble being unofficial" "description": "Remote access ID field label"
}, },
"sponsorOnGitHub": "Sponsor on GitHub", "remoteAccessIdHint": "Find this in Music Assistant Settings → Remote Access",
"@sponsorOnGitHub": { "@remoteAccessIdHint": {
"description": "GitHub Sponsors button label" "description": "Hint for remote access ID field"
}, },
"buyMeAKoFi": "Buy me a Ko-fi", "remoteAccessNote": "For Home Assistant add-on users only",
"@buyMeAKoFi": { "@remoteAccessNote": {
"description": "Ko-fi donation button label" "description": "Note about remote access availability"
}, },
"aboutEnsemble": "About Ensemble", "pleaseEnterRemoteId": "Please enter your Remote Access ID",
"@aboutEnsemble": { "@pleaseEnterRemoteId": {
"description": "About Ensemble section" "description": "Validation message for remote access ID"
}, },
"version": "Version", "remoteConnectionFailed": "Remote connection failed. Please check your Remote Access ID and credentials.",
"@version": { "@remoteConnectionFailed": {
"description": "Version label" "description": "Error message when remote connection fails"
}, },
"visitGitHubRepo": "Visit GitHub Repository", "scanQrCode": "Scan QR Code",
"@visitGitHubRepo": { "@scanQrCode": {
"description": "Link to GitHub repo" "description": "Title for QR code scanner screen"
}, },
"reportABug": "Report a Bug", "pointCameraAtQr": "Point your camera at the Remote Access QR code in Music Assistant settings",
"@reportABug": { "@pointCameraAtQr": {
"description": "Link to report issues" "description": "Instructions for scanning QR code"
}, },
"noRowsEnabled": "No rows enabled", "cameraPermissionDenied": "Camera permission is required to scan QR codes",
"@noRowsEnabled": { "@cameraPermissionDenied": {
"description": "Empty state when no home screen rows are enabled" "description": "Error message when camera permission is denied"
},
"pullToRefreshHint": "Pull down to refresh or enable rows in settings",
"@pullToRefreshHint": {
"description": "Hint when home screen is empty"
} }
} }
+1 -11
View File
@@ -491,15 +491,5 @@
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones", "showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos", "pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado para sync", "holdToSync": "Mantén presionado para sync",
"swipeToAdjustVolume": "Desliza para ajustar volumen", "swipeToAdjustVolume": "Desliza para ajustar volumen"
"aboutAndSupport": "Acerca de y Soporte",
"supportDevelopment": "Apoyar el Desarrollo",
"ensembleIsUnofficial": "Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.",
"sponsorOnGitHub": "Patrocina en GitHub",
"buyMeAKoFi": "Cómprame un Ko-fi",
"aboutEnsemble": "Acerca de Ensemble",
"version": "Versión",
"visitGitHubRepo": "Visitar Repositorio GitHub",
"reportABug": "Reportar un Error"
} }
+1 -11
View File
@@ -1396,15 +1396,5 @@
"type": "String" "type": "String"
} }
} }
}, }
"aboutAndSupport": "À propos et Support",
"supportDevelopment": "Soutenir le Développement",
"ensembleIsUnofficial": "Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.",
"sponsorOnGitHub": "Sponsoriser sur GitHub",
"buyMeAKoFi": "Offrez-moi un Ko-fi",
"aboutEnsemble": "À propos d'Ensemble",
"version": "Version",
"visitGitHubRepo": "Visiter le Dépôt GitHub",
"reportABug": "Signaler un Bug"
} }
+33 -135
View File
@@ -164,7 +164,7 @@ abstract class S {
/// Description for port field /// Description for port field
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Leave blank unless using a custom port'** /// **'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.'**
String get portDescription; String get portDescription;
/// Username field label /// Username field label
@@ -359,42 +359,6 @@ abstract class S {
/// **'Discover Albums'** /// **'Discover Albums'**
String get discoverAlbums; String get discoverAlbums;
/// Provider discovery mixes (e.g., Tidal playlists)
///
/// In en, this message translates to:
/// **'Discovery Mixes'**
String get discoveryMixes;
/// Description for discovery mixes setting
///
/// In en, this message translates to:
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
String get discoveryMixesDescription;
/// Label for discovery rows section
///
/// In en, this message translates to:
/// **'Discovery rows from Music Assistant'**
String get discoveryRows;
/// Button to refresh discovery rows
///
/// In en, this message translates to:
/// **'Refresh available rows'**
String get refreshDiscoveryRows;
/// Empty state when no discovery rows available
///
/// In en, this message translates to:
/// **'No discovery rows available'**
String get noDiscoveryRows;
/// Badge label for discovery rows
///
/// In en, this message translates to:
/// **'Music Assistant'**
String get discoveryRowBadge;
/// Home screen section title /// Home screen section title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -797,18 +761,12 @@ abstract class S {
/// **'Album added to queue'** /// **'Album added to queue'**
String get albumAddedToQueue; String get albumAddedToQueue;
/// Snackbar message for tracks added to queue /// Confirmation when tracks added
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Tracks added to queue'** /// **'Tracks added to queue'**
String get tracksAddedToQueue; String get tracksAddedToQueue;
/// Confirmation when tracks added
///
/// In en, this message translates to:
/// **'Track added to queue'**
String get trackAddedToQueue;
/// Play button label /// Play button label
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1271,30 +1229,6 @@ abstract class S {
/// **'Sort by series order'** /// **'Sort by series order'**
String get sortBySeriesOrder; String get sortBySeriesOrder;
/// No description provided for @sortByDuration.
///
/// In en, this message translates to:
/// **'Sort by duration'**
String get sortByDuration;
/// No description provided for @newestFirst.
///
/// In en, this message translates to:
/// **'Newest first'**
String get newestFirst;
/// No description provided for @oldestFirst.
///
/// In en, this message translates to:
/// **'Oldest first'**
String get oldestFirst;
/// No description provided for @viewDetails.
///
/// In en, this message translates to:
/// **'View details'**
String get viewDetails;
/// No description provided for @listView. /// No description provided for @listView.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1403,12 +1337,6 @@ abstract class S {
/// **'Choose which rows to display on the home screen'** /// **'Choose which rows to display on the home screen'**
String get chooseHomeScreenRows; String get chooseHomeScreenRows;
/// No description provided for @favorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get favorite;
/// No description provided for @addedToFavorites. /// No description provided for @addedToFavorites.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1436,7 +1364,7 @@ abstract class S {
/// No description provided for @addToLibrary. /// No description provided for @addToLibrary.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add'** /// **'Add to library'**
String get addToLibrary; String get addToLibrary;
/// No description provided for @unknown. /// No description provided for @unknown.
@@ -1745,24 +1673,12 @@ abstract class S {
/// **'Add album to queue on...'** /// **'Add album to queue on...'**
String get addAlbumToQueueOn; String get addAlbumToQueueOn;
/// Add item to queue on selected player
///
/// In en, this message translates to:
/// **'Add to queue'**
String get addToQueue;
/// Bottom sheet title for adding to queue /// Bottom sheet title for adding to queue
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add to queue on...'** /// **'Add to queue on...'**
String get addToQueueOn; String get addToQueueOn;
/// Start radio on selected player
///
/// In en, this message translates to:
/// **'Start radio'**
String get startRadio;
/// Bottom sheet title for starting radio /// Bottom sheet title for starting radio
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1919,18 +1835,6 @@ abstract class S {
/// **'Display helpful tips for discovering features'** /// **'Display helpful tips for discovering features'**
String get showHintsDescription; String get showHintsDescription;
/// Setting for fine volume control
///
/// In en, this message translates to:
/// **'Volume Precision Mode'**
String get volumePrecisionMode;
/// Description for volume precision mode setting
///
/// In en, this message translates to:
/// **'Hold still while adjusting volume for fine control'**
String get volumePrecisionModeDescription;
/// Settings section title for display options /// Settings section title for display options
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2219,71 +2123,65 @@ abstract class S {
/// **'Select player to add to queue'** /// **'Select player to add to queue'**
String get selectPlayerToAddToQueue; String get selectPlayerToAddToQueue;
/// About and support section title /// Direct connection mode toggle label
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'About & Support'** /// **'Server URL'**
String get aboutAndSupport; String get localConnection;
/// Support development section /// HA Remote Access connection mode toggle label
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Support Development'** /// **'Remote Access ID'**
String get supportDevelopment; String get remoteConnection;
/// Description about Ensemble being unofficial /// Remote access ID field label
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'** /// **'Remote Access ID'**
String get ensembleIsUnofficial; String get remoteAccessId;
/// GitHub Sponsors button label /// Hint for remote access ID field
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Sponsor on GitHub'** /// **'Find this in Music Assistant Settings → Remote Access'**
String get sponsorOnGitHub; String get remoteAccessIdHint;
/// Ko-fi donation button label /// Note about remote access availability
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Buy me a Ko-fi'** /// **'For Home Assistant add-on users only'**
String get buyMeAKoFi; String get remoteAccessNote;
/// About Ensemble section /// Validation message for remote access ID
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'About Ensemble'** /// **'Please enter your Remote Access ID'**
String get aboutEnsemble; String get pleaseEnterRemoteId;
/// Version label /// Error message when remote connection fails
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Version'** /// **'Remote connection failed. Please check your Remote Access ID and credentials.'**
String get version; String get remoteConnectionFailed;
/// Link to GitHub repo /// Title for QR code scanner screen
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Visit GitHub Repository'** /// **'Scan QR Code'**
String get visitGitHubRepo; String get scanQrCode;
/// Link to report issues /// Instructions for scanning QR code
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Report a Bug'** /// **'Point your camera at the Remote Access QR code in Music Assistant settings'**
String get reportABug; String get pointCameraAtQr;
/// Empty state when no home screen rows are enabled /// Error message when camera permission is denied
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'No rows enabled'** /// **'Camera permission is required to scan QR codes'**
String get noRowsEnabled; String get cameraPermissionDenied;
/// Hint when home screen is empty
///
/// In en, this message translates to:
/// **'Pull down to refresh or enable rows in settings'**
String get pullToRefreshHint;
} }
class _SDelegate extends LocalizationsDelegate<S> { class _SDelegate extends LocalizationsDelegate<S> {
+15 -66
View File
@@ -151,25 +151,6 @@ class SDe extends S {
@override @override
String get discoverAlbums => 'Alben entdecken'; String get discoverAlbums => 'Alben entdecken';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Weiterhoren'; String get continueListening => 'Weiterhoren';
@@ -392,9 +373,6 @@ class SDe extends S {
@override @override
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt'; String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Abspielen'; String get play => 'Abspielen';
@@ -668,18 +646,6 @@ class SDe extends S {
@override @override
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren'; String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override @override
String get listView => 'Listenansicht'; String get listView => 'Listenansicht';
@@ -739,9 +705,6 @@ class SDe extends S {
String get chooseHomeScreenRows => String get chooseHomeScreenRows =>
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden'; 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Zu Favoriten hinzugefügt'; String get addedToFavorites => 'Zu Favoriten hinzugefügt';
@@ -755,7 +718,7 @@ class SDe extends S {
String get removedFromLibrary => 'Removed from library'; String get removedFromLibrary => 'Removed from library';
@override @override
String get addToLibrary => 'Add'; String get addToLibrary => 'Add to library';
@override @override
String get unknown => 'Unbekannt'; String get unknown => 'Unbekannt';
@@ -924,15 +887,9 @@ class SDe extends S {
@override @override
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...'; String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...'; String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return '$name Radio starten auf...'; return '$name Radio starten auf...';
@@ -1027,13 +984,6 @@ class SDe extends S {
String get showHintsDescription => String get showHintsDescription =>
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen'; 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override @override
String get display => 'Display'; String get display => 'Display';
@@ -1207,37 +1157,36 @@ class SDe extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue'; String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override @override
String get aboutAndSupport => 'Über & Unterstützung'; String get localConnection => 'Server URL';
@override @override
String get supportDevelopment => 'Entwicklung unterstützen'; String get remoteConnection => 'Remote Access ID';
@override @override
String get ensembleIsUnofficial => String get remoteAccessId => 'Remote Access ID';
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
@override @override
String get sponsorOnGitHub => 'Auf GitHub sponsoren'; String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
@override @override
String get buyMeAKoFi => 'Kauf mir einen Ko-fi'; String get remoteAccessNote => 'For Home Assistant add-on users only';
@override @override
String get aboutEnsemble => 'Über Ensemble'; String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
@override @override
String get version => 'Version'; String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
@override @override
String get visitGitHubRepo => 'GitHub-Repository besuchen'; String get scanQrCode => 'Scan QR Code';
@override @override
String get reportABug => 'Einen Fehler melden'; String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
@override @override
String get noRowsEnabled => 'No rows enabled'; String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
} }
+17 -67
View File
@@ -39,7 +39,8 @@ class SEn extends S {
String get portHint => 'e.g., 8095 or leave blank'; String get portHint => 'e.g., 8095 or leave blank';
@override @override
String get portDescription => 'Leave blank unless using a custom port'; String get portDescription =>
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
@override @override
String get username => 'Username'; String get username => 'Username';
@@ -148,25 +149,6 @@ class SEn extends S {
@override @override
String get discoverAlbums => 'Discover Albums'; String get discoverAlbums => 'Discover Albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Continue Listening'; String get continueListening => 'Continue Listening';
@@ -387,9 +369,6 @@ class SEn extends S {
@override @override
String get tracksAddedToQueue => 'Tracks added to queue'; String get tracksAddedToQueue => 'Tracks added to queue';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Play'; String get play => 'Play';
@@ -662,18 +641,6 @@ class SEn extends S {
@override @override
String get sortBySeriesOrder => 'Sort by series order'; String get sortBySeriesOrder => 'Sort by series order';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override @override
String get listView => 'List view'; String get listView => 'List view';
@@ -731,9 +698,6 @@ class SEn extends S {
String get chooseHomeScreenRows => String get chooseHomeScreenRows =>
'Choose which rows to display on the home screen'; 'Choose which rows to display on the home screen';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Added to favorites'; String get addedToFavorites => 'Added to favorites';
@@ -747,7 +711,7 @@ class SEn extends S {
String get removedFromLibrary => 'Removed from library'; String get removedFromLibrary => 'Removed from library';
@override @override
String get addToLibrary => 'Add'; String get addToLibrary => 'Add to library';
@override @override
String get unknown => 'Unknown'; String get unknown => 'Unknown';
@@ -915,15 +879,9 @@ class SEn extends S {
@override @override
String get addAlbumToQueueOn => 'Add album to queue on...'; String get addAlbumToQueueOn => 'Add album to queue on...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Add to queue on...'; String get addToQueueOn => 'Add to queue on...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return 'Start $name radio on...'; return 'Start $name radio on...';
@@ -1018,13 +976,6 @@ class SEn extends S {
String get showHintsDescription => String get showHintsDescription =>
'Display helpful tips for discovering features'; 'Display helpful tips for discovering features';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override @override
String get display => 'Display'; String get display => 'Display';
@@ -1198,37 +1149,36 @@ class SEn extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue'; String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override @override
String get aboutAndSupport => 'About & Support'; String get localConnection => 'Server URL';
@override @override
String get supportDevelopment => 'Support Development'; String get remoteConnection => 'Remote Access ID';
@override @override
String get ensembleIsUnofficial => String get remoteAccessId => 'Remote Access ID';
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
@override @override
String get sponsorOnGitHub => 'Sponsor on GitHub'; String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
@override @override
String get buyMeAKoFi => 'Buy me a Ko-fi'; String get remoteAccessNote => 'For Home Assistant add-on users only';
@override @override
String get aboutEnsemble => 'About Ensemble'; String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
@override @override
String get version => 'Version'; String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
@override @override
String get visitGitHubRepo => 'Visit GitHub Repository'; String get scanQrCode => 'Scan QR Code';
@override @override
String get reportABug => 'Report a Bug'; String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
@override @override
String get noRowsEnabled => 'No rows enabled'; String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
} }
+15 -66
View File
@@ -152,25 +152,6 @@ class SEs extends S {
@override @override
String get discoverAlbums => 'Descubrir álbumes'; String get discoverAlbums => 'Descubrir álbumes';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Continuar escuchando'; String get continueListening => 'Continuar escuchando';
@@ -395,9 +376,6 @@ class SEs extends S {
@override @override
String get tracksAddedToQueue => 'Canciones añadidas a la cola'; String get tracksAddedToQueue => 'Canciones añadidas a la cola';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Reproducir'; String get play => 'Reproducir';
@@ -672,18 +650,6 @@ class SEs extends S {
@override @override
String get sortBySeriesOrder => 'Ordenar por orden de serie'; String get sortBySeriesOrder => 'Ordenar por orden de serie';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override @override
String get listView => 'Vista de lista'; String get listView => 'Vista de lista';
@@ -746,9 +712,6 @@ class SEs extends S {
String get chooseHomeScreenRows => String get chooseHomeScreenRows =>
'Elige qué filas mostrar en la pantalla de inicio'; 'Elige qué filas mostrar en la pantalla de inicio';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Añadido a favoritos'; String get addedToFavorites => 'Añadido a favoritos';
@@ -762,7 +725,7 @@ class SEs extends S {
String get removedFromLibrary => 'Removed from library'; String get removedFromLibrary => 'Removed from library';
@override @override
String get addToLibrary => 'Add'; String get addToLibrary => 'Add to library';
@override @override
String get unknown => 'Desconocido'; String get unknown => 'Desconocido';
@@ -932,15 +895,9 @@ class SEs extends S {
@override @override
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...'; String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Añadir a la cola en...'; String get addToQueueOn => 'Añadir a la cola en...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return 'Iniciar radio de $name en...'; return 'Iniciar radio de $name en...';
@@ -1035,13 +992,6 @@ class SEs extends S {
String get showHintsDescription => String get showHintsDescription =>
'Mostrar consejos útiles para descubrir funciones'; 'Mostrar consejos útiles para descubrir funciones';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override @override
String get display => 'Display'; String get display => 'Display';
@@ -1215,37 +1165,36 @@ class SEs extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue'; String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override @override
String get aboutAndSupport => 'Acerca de y Soporte'; String get localConnection => 'Server URL';
@override @override
String get supportDevelopment => 'Apoyar el Desarrollo'; String get remoteConnection => 'Remote Access ID';
@override @override
String get ensembleIsUnofficial => String get remoteAccessId => 'Remote Access ID';
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
@override @override
String get sponsorOnGitHub => 'Patrocina en GitHub'; String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
@override @override
String get buyMeAKoFi => 'Cómprame un Ko-fi'; String get remoteAccessNote => 'For Home Assistant add-on users only';
@override @override
String get aboutEnsemble => 'Acerca de Ensemble'; String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
@override @override
String get version => 'Versión'; String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
@override @override
String get visitGitHubRepo => 'Visitar Repositorio GitHub'; String get scanQrCode => 'Scan QR Code';
@override @override
String get reportABug => 'Reportar un Error'; String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
@override @override
String get noRowsEnabled => 'No rows enabled'; String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
} }
+14 -65
View File
@@ -153,25 +153,6 @@ class SFr extends S {
@override @override
String get discoverAlbums => 'Découvrir des albums'; String get discoverAlbums => 'Découvrir des albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
@override @override
String get continueListening => 'Continuer l\'écoute'; String get continueListening => 'Continuer l\'écoute';
@@ -396,9 +377,6 @@ class SFr extends S {
@override @override
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente'; String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
@override
String get trackAddedToQueue => 'Track added to queue';
@override @override
String get play => 'Lire'; String get play => 'Lire';
@@ -673,18 +651,6 @@ class SFr extends S {
@override @override
String get sortBySeriesOrder => 'Trier par ordre de série'; String get sortBySeriesOrder => 'Trier par ordre de série';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override @override
String get listView => 'Vue en liste'; String get listView => 'Vue en liste';
@@ -750,9 +716,6 @@ class SFr extends S {
String get chooseHomeScreenRows => String get chooseHomeScreenRows =>
'Choisir les rangees a afficher sur l\'ecran d\'accueil'; 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
@override
String get favorite => 'Favorite';
@override @override
String get addedToFavorites => 'Ajoute aux favoris'; String get addedToFavorites => 'Ajoute aux favoris';
@@ -939,15 +902,9 @@ class SFr extends S {
String get addAlbumToQueueOn => String get addAlbumToQueueOn =>
'Ajouter l\'album a la file d\'attente sur...'; 'Ajouter l\'album a la file d\'attente sur...';
@override
String get addToQueue => 'Add to queue';
@override @override
String get addToQueueOn => 'Ajouter a la file d\'attente sur...'; String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
@override
String get startRadio => 'Start radio';
@override @override
String startRadioOn(String name) { String startRadioOn(String name) {
return 'Demarrer la radio $name sur...'; return 'Demarrer la radio $name sur...';
@@ -1043,13 +1000,6 @@ class SFr extends S {
String get showHintsDescription => String get showHintsDescription =>
'Afficher des conseils utiles pour découvrir les fonctionnalites'; 'Afficher des conseils utiles pour découvrir les fonctionnalites';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override @override
String get display => 'Display'; String get display => 'Display';
@@ -1224,37 +1174,36 @@ class SFr extends S {
String get selectPlayerToAddToQueue => 'Select player to add to queue'; String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override @override
String get aboutAndSupport => 'À propos et Support'; String get localConnection => 'Server URL';
@override @override
String get supportDevelopment => 'Soutenir le Développement'; String get remoteConnection => 'Remote Access ID';
@override @override
String get ensembleIsUnofficial => String get remoteAccessId => 'Remote Access ID';
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
@override @override
String get sponsorOnGitHub => 'Sponsoriser sur GitHub'; String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
@override @override
String get buyMeAKoFi => 'Offrez-moi un Ko-fi'; String get remoteAccessNote => 'For Home Assistant add-on users only';
@override @override
String get aboutEnsemble => 'À propos d\'Ensemble'; String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
@override @override
String get version => 'Version'; String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
@override @override
String get visitGitHubRepo => 'Visiter le Dépôt GitHub'; String get scanQrCode => 'Scan QR Code';
@override @override
String get reportABug => 'Signaler un Bug'; String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
@override @override
String get noRowsEnabled => 'No rows enabled'; String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
} }
+29 -70
View File
@@ -31,11 +31,7 @@ late MassivAudioHandler audioHandler;
// Global debug logger instance // Global debug logger instance
final _logger = DebugLogger(); final _logger = DebugLogger();
void main() { Future<void> main() async {
// All initialization must run inside the same zone as runApp() to avoid
// "Zone mismatch" errors from Flutter's binding system
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance // Initialize SharedPreferences cache early for performance
@@ -108,8 +104,9 @@ void main() {
return true; // Handled return true; // Handled
}; };
runApp(const MusicAssistantApp()); // Wrap app in zone to catch async errors
}, runZonedGuarded(
() => runApp(const MusicAssistantApp()),
(error, stackTrace) { (error, stackTrace) {
_logger.error('Uncaught async error: $error', context: 'ZoneError'); _logger.error('Uncaught async error: $error', context: 'ZoneError');
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError'); _logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
@@ -131,7 +128,6 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
final _hardwareVolumeService = HardwareVolumeService(); final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub; StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub; StreamSubscription? _volumeDownSub;
StreamSubscription? _absoluteVolumeSub;
String? _lastSelectedPlayerId; String? _lastSelectedPlayerId;
String? _builtinPlayerId; String? _builtinPlayerId;
@@ -144,7 +140,6 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_musicProvider = MusicAssistantProvider(); _musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider(); _themeProvider = ThemeProvider();
_localeProvider = LocaleProvider(); _localeProvider = LocaleProvider();
audioHandler.setProvider(_musicProvider);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl(); _initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception // Listen to player selection changes to toggle volume interception
@@ -163,12 +158,6 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) { _volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
_adjustVolume(-_volumeStep); _adjustVolume(-_volumeStep);
}); });
// Listen for absolute volume changes from lockscreen/background
// (ContentObserver on system STREAM_MUSIC volume)
_absoluteVolumeSub = _hardwareVolumeService.onAbsoluteVolumeChange.listen((volume) {
_setAbsoluteVolume(volume);
});
} catch (e, stack) { } catch (e, stack) {
_logger.error('Hardware volume init failed', context: 'VolumeInit', error: e, stackTrace: stack); _logger.error('Hardware volume init failed', context: 'VolumeInit', error: e, stackTrace: stack);
} }
@@ -184,41 +173,18 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
} }
/// Enable/disable volume button interception based on selected player. /// Enable/disable volume button interception based on selected player.
/// Also manages the volume observer for lockscreen volume routing.
Future<void> _updateVolumeInterception() async { Future<void> _updateVolumeInterception() async {
final player = _musicProvider.selectedPlayer;
final isBuiltinPlayer = _builtinPlayerId != null && final isBuiltinPlayer = _builtinPlayerId != null &&
player?.playerId == _builtinPlayerId; _musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
await _hardwareVolumeService.setIntercepting(!isBuiltinPlayer); await _hardwareVolumeService.setIntercepting(!isBuiltinPlayer);
// Start/stop volume observer for lockscreen volume changes.
// When a remote/group player is active, observe system volume changes
// so lockscreen hardware buttons route to the MA player.
if (!isBuiltinPlayer && player != null) {
// Get the effective volume to sync the system volume HUD
final effectiveVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
: player.volume;
await _hardwareVolumeService.startVolumeObserver(initialVolume: effectiveVolume);
} else {
await _hardwareVolumeService.stopVolumeObserver();
}
} }
Future<void> _adjustVolume(int delta) async { Future<void> _adjustVolume(int delta) async {
final player = _musicProvider.selectedPlayer; final player = _musicProvider.selectedPlayer;
if (player == null) return; if (player == null) return;
// Use effective volume for group players (player.volume returns 0 final newVolume = (player.volume + delta).clamp(0, 100);
// because MA API returns null for group player volume_level) if (newVolume != player.volume) {
final currentVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
: player.volume;
final newVolume = (currentVolume + delta).clamp(0, 100);
if (newVolume != currentVolume) {
try { try {
await _musicProvider.setVolume(player.playerId, newVolume); await _musicProvider.setVolume(player.playerId, newVolume);
} catch (e) { } catch (e) {
@@ -227,29 +193,11 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
} }
} }
/// Handle absolute volume change from lockscreen/background.
/// Called when the ContentObserver detects a system volume change
/// while a remote/group player is active.
Future<void> _setAbsoluteVolume(int volume) async {
final player = _musicProvider.selectedPlayer;
if (player == null) return;
// Don't route to builtin player - that's handled by the system directly
if (_builtinPlayerId != null && player.playerId == _builtinPlayerId) return;
try {
await _musicProvider.setVolume(player.playerId, volume.clamp(0, 100));
} catch (e) {
_logger.error('Lockscreen volume change failed', context: 'VolumeControl', error: e);
}
}
@override @override
void dispose() { void dispose() {
_musicProvider.removeListener(_onProviderChanged); _musicProvider.removeListener(_onProviderChanged);
_volumeUpSub?.cancel(); _volumeUpSub?.cancel();
_volumeDownSub?.cancel(); _volumeDownSub?.cancel();
_absoluteVolumeSub?.cancel();
_hardwareVolumeService.dispose(); _hardwareVolumeService.dispose();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
@@ -381,13 +329,28 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
theme: AppTheme.lightTheme(colorScheme: lightColorScheme), theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme), darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) { builder: (context, child) {
// Wrap entire app with global player overlay. // Wrap entire app with global player overlay
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely. // Also include hidden WebView for remote WebRTC bridge
return Overlay( final musicProvider = context.read<MusicAssistantProvider>();
initialEntries: [ return Stack(
OverlayEntry( children: [
builder: (context) => GlobalPlayerOverlay( GlobalPlayerOverlay(child: child ?? const SizedBox.shrink()),
child: child ?? const SizedBox.shrink(), // WebView for WebRTC - must be VISIBLE to avoid Android throttling
// A tiny 4x4 pixel dot at bottom-right, nearly invisible but keeps WebView active
Positioned(
right: 4,
bottom: 4,
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1), // Nearly invisible dot
borderRadius: BorderRadius.circular(2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: musicProvider.remoteBridgeWebView,
),
), ),
), ),
], ],
@@ -536,9 +499,6 @@ class _AppStartupState extends State<AppStartup> {
Future<void> _checkAndConnect() async { Future<void> _checkAndConnect() async {
final serverUrl = await SettingsService.getServerUrl(); final serverUrl = await SettingsService.getServerUrl();
// DEBUG: Log where server URL is coming from
_logger.log('🐛 DEBUG: Server URL from SharedPreferences: $serverUrl');
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -547,7 +507,6 @@ class _AppStartupState extends State<AppStartup> {
// If we have a saved server URL, attempt auto-connection // If we have a saved server URL, attempt auto-connection
if (serverUrl != null && serverUrl.isNotEmpty) { if (serverUrl != null && serverUrl.isNotEmpty) {
_logger.log('🐛 DEBUG: Auto-connecting because server URL exists');
setState(() { setState(() {
_isConnecting = true; _isConnecting = true;
}); });
+8 -32
View File
@@ -31,14 +31,20 @@ class ProviderMapping {
}); });
factory ProviderMapping.fromJson(Map<String, dynamic> json) { factory ProviderMapping.fromJson(Map<String, dynamic> json) {
// Parse in_library field - MA uses 1/0 or true/false
// Default to true if not present (items from library_items API are in library)
final inLibraryValue = json['in_library'];
final inLibrary = inLibraryValue == null
? true // Default: items fetched from library are in library
: inLibraryValue == true || inLibraryValue == 1;
return ProviderMapping( return ProviderMapping(
itemId: json['item_id'] as String? ?? '', itemId: json['item_id'] as String? ?? '',
providerDomain: json['provider_domain'] as String? ?? '', providerDomain: json['provider_domain'] as String? ?? '',
providerInstance: json['provider_instance'] as String? ?? '', providerInstance: json['provider_instance'] as String? ?? '',
available: json['available'] as bool? ?? true, available: json['available'] as bool? ?? true,
audioFormat: json['audio_format'] as Map<String, dynamic>?, audioFormat: json['audio_format'] as Map<String, dynamic>?,
// Parse in_library field - MA uses 1/0 or true/false inLibrary: inLibrary,
inLibrary: json['in_library'] == true || json['in_library'] == 1,
); );
} }
@@ -137,12 +143,6 @@ class MediaItem {
@override @override
int get hashCode => Object.hash(itemId, provider); int get hashCode => Object.hash(itemId, provider);
/// Check if this item is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
} }
class Artist extends MediaItem { class Artist extends MediaItem {
@@ -171,12 +171,6 @@ class Artist extends MediaItem {
); );
} }
/// Check if this artist is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override @override
Map<String, dynamic> toJson() => super.toJson(); Map<String, dynamic> toJson() => super.toJson();
} }
@@ -298,12 +292,6 @@ class Track extends MediaItem {
String get artistsString => String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist'; artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
/// Check if this track is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = super.toJson(); final json = super.toJson();
@@ -350,12 +338,6 @@ class Playlist extends MediaItem {
favorite: item.favorite, favorite: item.favorite,
); );
} }
/// Check if this playlist is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
} }
class Chapter { class Chapter {
@@ -552,12 +534,6 @@ class Audiobook extends MediaItem {
String get narratorsString => String get narratorsString =>
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator'; narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
/// Check if this audiobook is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Get progress as a percentage (0.0 to 1.0) /// Get progress as a percentage (0.0 to 1.0)
double get progress { double get progress {
if (fullyPlayed == true) return 1.0; if (fullyPlayed == true) return 1.0;
+24 -41
View File
@@ -17,7 +17,6 @@ class Player {
final String? activeSource; // The currently active source for this player final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
final String? activeQueue; // The active_queue from MA - has value when MA is controlling playback
Player({ Player({
required this.playerId, required this.playerId,
@@ -36,7 +35,6 @@ class Player {
this.activeSource, this.activeSource,
this.isExternalSource = false, this.isExternalSource = false,
this.appId, this.appId,
this.activeQueue,
}); });
/// Create a copy of this Player with some fields replaced /// Create a copy of this Player with some fields replaced
@@ -57,7 +55,6 @@ class Player {
String? activeSource, String? activeSource,
bool? isExternalSource, bool? isExternalSource,
String? appId, String? appId,
String? activeQueue,
}) { }) {
return Player( return Player(
playerId: playerId ?? this.playerId, playerId: playerId ?? this.playerId,
@@ -76,7 +73,6 @@ class Player {
activeSource: activeSource ?? this.activeSource, activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource, isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId, appId: appId ?? this.appId,
activeQueue: activeQueue ?? this.activeQueue,
); );
} }
@@ -222,61 +218,50 @@ class Player {
// Parse active_source - the currently active source for this player // Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?; final activeSource = json['active_source'] as String?;
// Parse active_queue - this is the key indicator for MA-controlled playback // Detect external source (optical, Spotify, AirPlay, etc.)
// When MA is playing: active_queue = 'uuid:...' or similar queue ID // Primary indicator: app_id - if not 'music_assistant', it's an external source
// When external source: active_queue = null // Secondary indicators: URI patterns and media_type
final activeQueue = json['active_queue'] as String?;
// Parse app_id - helps distinguish external sources on DLNA players
final appId = json['app_id'] as String?;
// Detect external source (optical, Spotify Connect, AirPlay, etc.)
//
// Detection logic priority:
// 1. If app_id == 'music_assistant' -> MA is playing, NOT external
// 2. If active_queue has a value -> MA has a queue, NOT external
// (This handles Spotify as music provider where URI is spotify:// but MA controls playback)
// 3. If app_id is set and NOT 'music_assistant' -> external source (e.g., 'http', 'spotify')
// 4. Fallback: Check URI patterns for simple external IDs (optical, line_in, etc.)
bool isExternalSource = false; bool isExternalSource = false;
// If MA explicitly says it's playing, trust that // Check app_id first - this is the most reliable indicator for DLNA players
if (appId == 'music_assistant') { // When MA is playing: app_id = 'music_assistant'
isExternalSource = false; // When external source: app_id = 'http', 'spotify', etc.
} final appId = json['app_id'] as String?;
// If there's an active queue, MA is controlling playback (even with spotify:// URIs) if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
else if (activeQueue != null && activeQueue.isNotEmpty) { // app_id is set but not 'music_assistant' - external source is active
isExternalSource = false;
}
// If app_id is set to something else (http, spotify, etc.), it's external
else if (appId != null && appId.isNotEmpty) {
isExternalSource = true; isExternalSource = true;
} }
// Fallback: check current_media for simple external source indicators
// Only check these when we have no app_id and no active_queue // Also check current_media for additional external source indicators
else if (json.containsKey('current_media')) { if (!isExternalSource && json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?; final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) { if (currentMedia != null) {
final uri = currentMedia['uri'] as String?; final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?; final mediaType = currentMedia['media_type'] as String?;
// Only check for simple external source identifiers (physical inputs) // External source indicators:
// Do NOT flag spotify://, airplay://, etc. as external here - // 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// those should only be external if app_id indicates external control // 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
if (uri != null) { if (uri != null) {
final uriLower = uri.toLowerCase(); final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /) // Simple external source identifiers (no :// or /)
// These are physical inputs that are always external
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') && final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' || (uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' || uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink'); uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId; isExternalSource = isSimpleExternalId || isExternalProtocol;
} }
// Also check media_type - 'unknown' with non-MA URI indicates external source // Also check media_type - 'unknown' often indicates external source
if (!isExternalSource && mediaType == 'unknown') { if (!isExternalSource && mediaType == 'unknown') {
// If media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?; final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) { if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true; isExternalSource = true;
@@ -302,7 +287,6 @@ class Player {
activeSource: activeSource, activeSource: activeSource,
isExternalSource: isExternalSource, isExternalSource: isExternalSource,
appId: appId, appId: appId,
activeQueue: activeQueue,
); );
} }
@@ -324,7 +308,6 @@ class Player {
if (activeSource != null) 'active_source': activeSource, if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource, 'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId, if (appId != null) 'app_id': appId,
if (activeQueue != null) 'active_queue': activeQueue,
}; };
} }
} }
-69
View File
@@ -1,69 +0,0 @@
import 'media_item.dart';
class RecommendationFolder {
final String itemId;
final String provider;
final String name;
final String? uri;
final List<MediaItem> items;
RecommendationFolder({
required this.itemId,
required this.provider,
required this.name,
this.uri,
required this.items,
});
/// Unique ID for use in row ordering and settings
/// Uses 'discovery:' prefix to distinguish from static row IDs
String get rowId => 'discovery:$itemId';
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
final itemsJson = json['items'] as List<dynamic>?;
final parsedItems = itemsJson
?.whereType<Map<String, dynamic>>()
.map(_parseMediaItemJson)
.toList() ?? <MediaItem>[];
return RecommendationFolder(
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
provider: json['provider'] as String? ?? 'unknown',
name: json['name'] as String? ?? '',
uri: json['uri'] as String?,
items: parsedItems,
);
}
Map<String, dynamic> toJson() {
return {
'item_id': itemId,
'provider': provider,
'name': name,
if (uri != null) 'uri': uri,
'items': items.map((item) => item.toJson()).toList(),
};
}
}
/// Polymorphic parser for media items in recommendation folders
/// Handles heterogeneous items (playlists, radio, etc.)
MediaItem _parseMediaItemJson(Map<String, dynamic> json) {
final mediaType = json['media_type'] as String? ?? '';
switch (mediaType) {
case 'playlist':
return Playlist.fromJson(json);
case 'album':
return Album.fromJson(json);
case 'artist':
return Artist.fromJson(json);
case 'track':
return Track.fromJson(json);
case 'audiobook':
return Audiobook.fromJson(json);
default:
// For radio, podcast, podcast_episode - use MediaItem base class
return MediaItem.fromJson(json);
}
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -17,9 +17,8 @@ class NavigationProvider extends ChangeNotifier {
if (previousIndex != index) { if (previousIndex != index) {
_selectedIndex = index; _selectedIndex = index;
notifyListeners(); notifyListeners();
}
// Trigger search focus callback when switching to or re-tapping search tab // Trigger search focus callback when switching to search tab
if (index == 2 && onSearchTabSelected != null) { if (index == 2 && onSearchTabSelected != null) {
// Use post-frame callback to ensure SearchScreen is built first // Use post-frame callback to ensure SearchScreen is built first
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -27,6 +26,7 @@ class NavigationProvider extends ChangeNotifier {
}); });
} }
} }
}
/// Pop all routes and return to the home tab /// Pop all routes and return to the home tab
void popToHome() { void popToHome() {
+285 -334
View File
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
@@ -11,12 +9,8 @@ import '../theme/theme_provider.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../services/library_status_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../widgets/library_status_builder.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import 'artist_details_screen.dart'; import 'artist_details_screen.dart';
@@ -38,12 +32,15 @@ class AlbumDetailsScreen extends StatefulWidget {
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState(); State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
} }
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin, LibraryStatusMixin { class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin {
final _logger = DebugLogger(); final _logger = DebugLogger();
List<Track> _tracks = []; List<Track> _tracks = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
String? _albumDescription; String? _albumDescription;
Album? _freshAlbum; // Full album data with image metadata Album? _freshAlbum; // Full album data with image metadata
@@ -53,42 +50,24 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'album',
widget.album.provider,
widget.album.itemId,
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize status in centralized service from widget data _isFavorite = widget.album.favorite ?? false;
final service = LibraryStatusService.instance; _isInLibrary = widget.album.inLibrary;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
_loadTracks(); _loadTracks();
_loadAlbumDescription(); _loadAlbumDescription();
// Mark that we're on a detail screen and extract colors immediately // CRITICAL FIX: Defer both fresh data loading AND color extraction until
WidgetsBinding.instance.addPostFrameCallback((_) { // after the hero animation completes. This prevents:
if (mounted) { // 1. setState with new image URL during animation → grey icon flash
markDetailScreenEntered(context); // 2. Expensive palette extraction blocking animation frames
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer fresh data loading until after hero animation completes
// This prevents setState with new image URL during animation → grey icon flash
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
Future.delayed(const Duration(milliseconds: 350), () { Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) { if (mounted) {
_loadFreshAlbumData(); _loadFreshAlbumData();
_extractColors();
} }
}); });
}); });
@@ -111,14 +90,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (freshAlbum != null && mounted) { if (freshAlbum != null && mounted) {
setState(() { setState(() {
_freshAlbum = freshAlbum; _freshAlbum = freshAlbum;
_isFavorite = freshAlbum.favorite ?? false;
}); });
// Sync fresh data to centralized service
final service = LibraryStatusService.instance;
service.syncSingleItem(
key: libraryItemKey,
inLibrary: freshAlbum.inLibrary,
favorite: freshAlbum.favorite ?? false,
);
// Re-extract colors now that we have fresh album with images // Re-extract colors now that we have fresh album with images
_extractColors(); _extractColors();
} }
@@ -134,18 +107,15 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (imageUrl == null) return; if (imageUrl == null) return;
try { try {
// Use isolate-based extraction to avoid blocking the main thread final colorSchemes = await PaletteHelper.extractColorSchemes(
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl); CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) { if (colorSchemes != null && mounted) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _darkColorScheme = colorSchemes.$2;
}); });
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
} }
} catch (e) { } catch (e) {
_logger.log('Failed to extract colors for album: $e'); _logger.log('Failed to extract colors for album: $e');
@@ -154,13 +124,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _toggleFavorite() async { Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try { try {
final newState = !_isFavorite;
bool success; bool success;
if (newState) { if (newState) {
@@ -206,7 +172,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (libraryItemId == null) { if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal'); _logger.log('Error: Could not determine library_item_id for removal');
rollbackFavoriteOperation();
throw Exception('Could not determine library ID for this album'); throw Exception('Could not determine library ID for this album');
} }
@@ -217,7 +182,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
if (success) { if (success) {
completeFavoriteOperation(); // Optimistically update UI regardless of online/offline state
setState(() {
_isFavorite = newState;
});
// Invalidate home cache so the home screen shows updated favorite status // Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache(); maProvider.invalidateHomeCache();
@@ -228,17 +197,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
content: Text( content: Text(
isOffline isOffline
? S.of(context)!.actionQueuedForSync ? S.of(context)!.actionQueuedForSync
: (newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites), : (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
), ),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
} }
} else {
rollbackFavoriteOperation();
} }
} catch (e) { } catch (e) {
rollbackFavoriteOperation();
_logger.log('Error toggling favorite: $e'); _logger.log('Error toggling favorite: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -254,10 +220,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
/// Toggle library status /// Toggle library status
Future<void> _toggleLibrary() async { Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try { try {
final newState = !_isInLibrary;
if (newState) { if (newState) {
// Add to library - MUST use non-library provider // Add to library - MUST use non-library provider
String? actualProvider; String? actualProvider;
@@ -287,8 +253,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
} }
// OPTIMISTIC UPDATE via centralized service // OPTIMISTIC UPDATE: Update UI immediately
setLibraryStatus(newState); setState(() {
_isInLibrary = newState;
});
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -300,17 +268,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId'); _logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
final success = await maProvider.addToLibrary( // Fire and forget - API call happens in background
maProvider.addToLibrary(
mediaType: 'album', mediaType: 'album',
provider: actualProvider, provider: actualProvider,
itemId: actualItemId, itemId: actualItemId,
); ).catchError((e) {
_logger.log('❌ Failed to add album to library: $e');
if (success) { // Revert on failure
completeLibraryOperation(); if (mounted) {
} else { setState(() {
rollbackLibraryOperation(); _isInLibrary = !newState;
});
} }
});
} else { } else {
// Remove from library // Remove from library
int? libraryItemId; int? libraryItemId;
@@ -331,8 +302,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
return; return;
} }
// OPTIMISTIC UPDATE via centralized service // OPTIMISTIC UPDATE: Update UI immediately
setLibraryStatus(newState); setState(() {
_isInLibrary = newState;
});
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -343,19 +316,21 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
); );
} }
final success = await maProvider.removeFromLibrary( // Fire and forget - API call happens in background
maProvider.removeFromLibrary(
mediaType: 'album', mediaType: 'album',
libraryItemId: libraryItemId, libraryItemId: libraryItemId,
); ).catchError((e) {
_logger.log('❌ Failed to remove album from library: $e');
if (success) { // Revert on failure
completeLibraryOperation(); if (mounted) {
} else { setState(() {
rollbackLibraryOperation(); _isInLibrary = !newState;
});
} }
});
} }
} catch (e) { } catch (e) {
rollbackLibraryOperation();
_logger.log('Error toggling album library: $e'); _logger.log('Error toggling album library: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -469,89 +444,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
} }
Future<void> _toggleTrackLibrary(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = track.inLibrary;
try {
if (!currentInLibrary) {
// Add to library
String? actualProvider;
String? actualItemId;
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
final nonLibraryMapping = track.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (track.provider != 'library') {
actualProvider = track.provider;
actualItemId = track.itemId;
} else {
return;
}
}
await maProvider.addToLibrary(
mediaType: 'track',
provider: actualProvider,
itemId: actualItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
} else {
// Remove from library
int? libraryItemId;
if (track.provider == 'library') {
libraryItemId = int.tryParse(track.itemId);
} else if (track.providerMappings != null) {
final libraryMapping = track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
await maProvider.removeFromLibrary(
mediaType: 'track',
libraryItemId: libraryItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling track library: $e');
}
}
Future<void> _loadTracks() async { Future<void> _loadTracks() async {
final provider = context.read<MusicAssistantProvider>(); final provider = context.read<MusicAssistantProvider>();
final cacheKey = '${widget.album.provider}_${widget.album.itemId}'; final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
@@ -573,7 +465,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
widget.album.provider, widget.album.provider,
widget.album.itemId, widget.album.itemId,
forceRefresh: cachedTracks != null, // Force refresh if we had cache forceRefresh: cachedTracks != null, // Force refresh if we had cache
album: widget.album,
); );
// 3. Update if we got different data // 3. Update if we got different data
@@ -651,15 +542,55 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _addAlbumToQueue() { void _addAlbumToQueue() {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.showPlayerSelectorForAction( // Slide mini player down out of the way
contextHint: S.of(context)!.addAlbumToQueueOn, GlobalPlayerOverlay.hidePlayer();
onPlayerSelected: (player) async {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addAlbumToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try { try {
_logger.log('Adding album to queue on ${player.name}'); _logger.log('Adding album to queue on ${player.name}');
await maProvider.addTracksToQueue( await maProvider.playTracks(
player.playerId, player.playerId,
_tracks, _tracks,
startIndex: 0,
clearQueue: false,
); );
_logger.log('Album added to queue on ${player.name}'); _logger.log('Album added to queue on ${player.name}');
if (mounted) { if (mounted) {
@@ -676,20 +607,70 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
}, },
); );
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Show mini player again when sheet closes
GlobalPlayerOverlay.showPlayer();
});
} }
void _addTrackToQueue(BuildContext context, int index) { void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.showPlayerSelectorForAction( // Slide mini player down out of the way
contextHint: S.of(context)!.addToQueueOn, GlobalPlayerOverlay.hidePlayer();
onPlayerSelected: (player) async {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try { try {
// Add tracks from this index onwards to queue // Add tracks from this index onwards to queue
await maProvider.addTracksToQueue( await maProvider.playTracks(
player.playerId, player.playerId,
_tracks, _tracks,
startIndex: index, startIndex: index,
clearQueue: false,
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -705,6 +686,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
} }
}, },
); );
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
} }
void _navigateToArtist() { void _navigateToArtist() {
@@ -848,9 +840,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70; final expandedHeight = coverSize + 70;
return Stack( return CustomScrollView(
children: [
CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: expandedHeight, expandedHeight: expandedHeight,
@@ -938,7 +928,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0), padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -975,7 +965,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
), ),
), ),
Spacing.vGap8, Spacing.vGap16,
// Album Description // Album Description
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[ if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
InkWell( InkWell(
@@ -1003,6 +993,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [ children: [
// Main Play Button // Main Play Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -1022,27 +1013,38 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// Library Button // "Play on..." Button (Square)
SizedBox( SizedBox(
height: 50, height: 50,
width: 50, width: 50,
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: _toggleLibrary, onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: Icon( child: const Icon(Icons.speaker_group_outlined),
Symbols.book_2,
size: 25,
fill: isInLibrary ? 1 : 0,
color: isInLibrary
? colorScheme.primary
: Colors.white70,
), ),
), ),
const SizedBox(width: 12),
// "Add Album to Queue" Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : _addAlbumToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add),
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -1060,40 +1062,22 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
), ),
child: Icon( child: Icon(
Icons.favorite, _isFavorite ? Icons.favorite : Icons.favorite_border,
size: 25, color: _isFavorite
color: isFavorite
? colorScheme.error ? colorScheme.error
: Colors.white70, : colorScheme.onSurfaceVariant,
), ),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// Three-dot Menu Button // Library Button
SizedBox( SizedBox(
height: 50, height: 50,
width: 50, width: 50,
child: Builder( child: FilledButton.tonal(
builder: (buttonContext) => FilledButton.tonal( onPressed: _toggleLibrary,
onPressed: () {
if (_isLoading || _tracks.isEmpty) return;
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.album,
item: widget.album,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false, // Only show list items since buttons are already visible
);
},
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -1101,18 +1085,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
), ),
child: Icon( child: Icon(
Icons.more_vert, _isInLibrary ? Icons.library_add_check : Icons.library_add,
size: 25, color: _isInLibrary
color: _isLoading || _tracks.isEmpty ? colorScheme.primary
? Colors.white70.withOpacity(0.38) : colorScheme.onSurfaceVariant,
: Colors.white70,
),
), ),
), ),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 24),
], ],
), ),
), ),
@@ -1140,22 +1122,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final track = _tracks[index]; final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
return GestureDetector( return Column(
onLongPressStart: (details) { mainAxisSize: MainAxisSize.min,
MediaContextMenu.show( children: [
context: context, ListTile(
position: details.globalPosition,
mediaType: ContextMenuMediaType.track,
item: track,
isFavorite: track.favorite ?? false,
isInLibrary: track.inLibrary,
onToggleFavorite: () => _toggleTrackFavorite(index),
onToggleLibrary: () => _toggleTrackLibrary(index),
adaptiveColorScheme: adaptiveScheme,
);
},
child: ListTile(
leading: Container( leading: Container(
width: 40, width: 40,
height: 40, height: 40,
@@ -1190,28 +1162,98 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
trailing: Row( trailing: track.duration != null
mainAxisSize: MainAxisSize.min, ? Text(
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!), _formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.onSurface.withOpacity(0.5),
), ),
textAlign: TextAlign.right, )
: null,
onTap: () {
if (isExpanded) {
// Single tap to collapse when expanded
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Radio button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.radio, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
const SizedBox(width: 10),
// Favorite button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _toggleTrackFavorite(index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
track.favorite == true ? Icons.favorite : Icons.favorite_border,
size: 20,
color: track.favorite == true
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
), ),
), ),
], ],
), ),
onTap: () => _playTrack(index), )
: const SizedBox.shrink(),
), ),
],
); );
}, },
childCount: _tracks.length, childCount: _tracks.length,
@@ -1219,28 +1261,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
), ),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
], ],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background,
],
),
),
),
),
),
],
); );
}, },
), ),
@@ -1289,75 +1309,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
); );
} }
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
final maProvider = context.read<MusicAssistantProvider>();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
ListTile(
leading: Icon(Icons.speaker_group_outlined, color: colorScheme.onSurface),
title: Text(S.of(context)!.playOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_showPlayOnMenu(context);
},
),
ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
// Add to queue on selected player
final player = maProvider.selectedPlayer;
if (player != null) {
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.albumAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
});
} else {
_showError(S.of(context)!.noPlayerSelected);
}
},
),
ListTile(
leading: Icon(Icons.queue_music, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueueOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_addAlbumToQueue();
},
),
const SizedBox(height: 8),
],
),
),
);
}
String _formatDuration(Duration duration) { String _formatDuration(Duration duration) {
final minutes = duration.inMinutes; final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60; final seconds = duration.inSeconds % 60;
+203 -473
View File
@@ -1,13 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import 'album_details_screen.dart'; import 'album_details_screen.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
@@ -18,8 +15,6 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../services/library_status_service.dart';
import '../widgets/library_status_builder.dart';
class ArtistDetailsScreen extends StatefulWidget { class ArtistDetailsScreen extends StatefulWidget {
final Artist artist; final Artist artist;
@@ -37,11 +32,13 @@ class ArtistDetailsScreen extends StatefulWidget {
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState(); State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
} }
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryStatusMixin { class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
final _logger = DebugLogger(); final _logger = DebugLogger();
List<Album> _albums = []; List<Album> _albums = [];
List<Album> _providerAlbums = []; List<Album> _providerAlbums = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
@@ -49,13 +46,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
String? _artistImageUrl; String? _artistImageUrl;
MusicAssistantProvider? _maProvider; MusicAssistantProvider? _maProvider;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'artist',
widget.artist.provider,
widget.artist.itemId,
);
// View preferences // View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year' String _sortOrder = 'alpha'; // 'alpha' or 'year'
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list' String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
@@ -65,15 +55,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize status in centralized service from widget data _isFavorite = widget.artist.favorite ?? false;
final service = LibraryStatusService.instance; _isInLibrary = _checkIfInLibrary(widget.artist);
final key = libraryItemKey;
if (!service.isInLibrary(key) && _checkIfInLibrary(widget.artist)) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
// Use initial image URL immediately for smooth hero animation // Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl; _artistImageUrl = widget.initialImageUrl;
_loadViewPreferences(); _loadViewPreferences();
@@ -81,18 +64,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
_loadArtistDescription(); _loadArtistDescription();
_refreshFavoriteStatus(); _refreshFavoriteStatus();
// Mark that we're on a detail screen and extract colors immediately // Defer higher-res image loading and color extraction until after transition
WidgetsBinding.instance.addPostFrameCallback((_) { // But hero animation now works because we have initialImageUrl
if (mounted) {
markDetailScreenEntered(context);
// Extract colors from initial image URL if available
if (widget.initialImageUrl != null) {
_extractColors(widget.initialImageUrl!);
}
}
});
// Defer higher-res image loading until after transition
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () { Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) { if (mounted) {
@@ -110,12 +83,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
void _onProviderChanged() { void _onProviderChanged() {
if (!mounted) return; if (!mounted) return;
// Re-check library status when provider data changes and sync to service // Re-check library status when provider data changes
final newIsInLibrary = _checkIfInLibraryFromProvider(); final newIsInLibrary = _checkIfInLibraryFromProvider();
final service = LibraryStatusService.instance; if (newIsInLibrary != _isInLibrary) {
final key = libraryItemKey; setState(() {
if (newIsInLibrary != service.isInLibrary(key)) { _isInLibrary = newIsInLibrary;
service.setLibraryStatus(key, newIsInLibrary); });
} }
} }
@@ -153,15 +126,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
} }
Future<void> _loadViewPreferences() async { Future<void> _loadViewPreferences() async {
// Parallelize settings service calls final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
final results = await Future.wait([ final viewMode = await SettingsService.getArtistAlbumsViewMode();
SettingsService.getArtistAlbumsSortOrder(),
SettingsService.getArtistAlbumsViewMode(),
]);
if (mounted) { if (mounted) {
setState(() { setState(() {
_sortOrder = results[0]; _sortOrder = sortOrder;
_viewMode = results[1]; _viewMode = viewMode;
}); });
} }
} }
@@ -228,13 +198,9 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
try { try {
final freshArtist = await maProvider.api!.getArtistByUri(artistUri); final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
if (freshArtist != null && mounted) { if (freshArtist != null && mounted) {
// Sync with centralized service setState(() {
final service = LibraryStatusService.instance; _isFavorite = freshArtist.favorite ?? false;
final key = libraryItemKey; });
final newFavorite = freshArtist.favorite ?? false;
if (service.isFavorite(key) != newFavorite) {
service.setFavoriteStatus(key, newFavorite);
}
} }
} catch (e) { } catch (e) {
_logger.log('Error refreshing artist favorite status: $e'); _logger.log('Error refreshing artist favorite status: $e');
@@ -243,13 +209,9 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
Future<void> _toggleFavorite() async { Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try { try {
final newState = !_isFavorite;
bool success; bool success;
if (newState) { if (newState) {
@@ -292,7 +254,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
if (libraryItemId == null) { if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal'); _logger.log('Error: Could not determine library_item_id for removal');
rollbackFavoriteOperation();
throw Exception('Could not determine library ID for this artist'); throw Exception('Could not determine library ID for this artist');
} }
@@ -303,7 +264,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
} }
if (success) { if (success) {
completeFavoriteOperation(); setState(() {
_isFavorite = newState;
});
maProvider.invalidateHomeCache(); maProvider.invalidateHomeCache();
if (mounted) { if (mounted) {
@@ -313,17 +277,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
content: Text( content: Text(
isOffline isOffline
? S.of(context)!.actionQueuedForSync ? S.of(context)!.actionQueuedForSync
: (newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites), : (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
), ),
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
} }
} else {
rollbackFavoriteOperation();
} }
} catch (e) { } catch (e) {
rollbackFavoriteOperation();
_logger.log('Error toggling artist favorite: $e'); _logger.log('Error toggling artist favorite: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -345,10 +306,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
/// Toggle library status /// Toggle library status
Future<void> _toggleLibrary() async { Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try { try {
final newState = !_isInLibrary;
if (newState) { if (newState) {
// Add to library - MUST use non-library provider // Add to library - MUST use non-library provider
String? actualProvider; String? actualProvider;
@@ -378,8 +339,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
} }
} }
// Optimistic update via centralized service // OPTIMISTIC UPDATE: Update UI immediately
setLibraryStatus(newState); setState(() {
_isInLibrary = newState;
});
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -391,17 +354,21 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
} }
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId'); _logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
final success = await maProvider.addToLibrary( // Fire and forget - API call happens in background
maProvider.addToLibrary(
mediaType: 'artist', mediaType: 'artist',
provider: actualProvider, provider: actualProvider,
itemId: actualItemId, itemId: actualItemId,
); ).catchError((e) {
_logger.log('❌ Failed to add artist to library: $e');
if (success) { // Revert on failure
completeLibraryOperation(); if (mounted) {
} else { setState(() {
rollbackLibraryOperation(); _isInLibrary = !newState;
});
} }
return false;
});
} else { } else {
// Remove from library // Remove from library
int? libraryItemId; int? libraryItemId;
@@ -422,8 +389,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
return; return;
} }
// Optimistic update via centralized service // OPTIMISTIC UPDATE: Update UI immediately
setLibraryStatus(newState); setState(() {
_isInLibrary = newState;
});
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -434,19 +403,22 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
); );
} }
final success = await maProvider.removeFromLibrary( // Fire and forget - API call happens in background
maProvider.removeFromLibrary(
mediaType: 'artist', mediaType: 'artist',
libraryItemId: libraryItemId, libraryItemId: libraryItemId,
); ).catchError((e) {
_logger.log('❌ Failed to remove artist from library: $e');
if (success) { // Revert on failure
completeLibraryOperation(); if (mounted) {
} else { setState(() {
rollbackLibraryOperation(); _isInLibrary = !newState;
});
} }
return false;
});
} }
} catch (e) { } catch (e) {
rollbackLibraryOperation();
_logger.log('Error toggling artist library: $e'); _logger.log('Error toggling artist library: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -459,44 +431,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
} }
} }
/// Start radio on the selected player directly void _showRadioMenu(BuildContext context) {
void _startRadio(BuildContext context) async {
final maProvider = context.read<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
if (player == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
}
return;
}
try {
await maProvider.playArtistRadio(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
void _showRadioOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction( GlobalPlayerOverlay.showPlayerSelectorForAction(
@@ -527,50 +462,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
); );
} }
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
ListTile(
leading: Icon(Icons.speaker_group_outlined, color: colorScheme.onSurface),
title: Text(S.of(context)!.startRadioOn(widget.artist.name), style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_showRadioOnMenu(context);
},
),
ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueueOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_showAddToQueueMenu(context);
},
),
const SizedBox(height: 8),
],
),
),
);
}
void _showAddToQueueMenu(BuildContext context) { void _showAddToQueueMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -628,18 +519,15 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
Future<void> _extractColors(String imageUrl) async { Future<void> _extractColors(String imageUrl) async {
try { try {
// Use isolate-based extraction to avoid blocking the main thread final colorSchemes = await PaletteHelper.extractColorSchemes(
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl); CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) { if (colorSchemes != null && mounted) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _darkColorScheme = colorSchemes.$2;
}); });
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
} }
} catch (e) { } catch (e) {
_logger.log('Failed to extract colors for artist: $e'); _logger.log('Failed to extract colors for artist: $e');
@@ -680,19 +568,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
_isLoading = false; _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) // 2. Fetch fresh data in background (silent refresh)
@@ -703,14 +578,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
); );
if (mounted && allAlbums.isNotEmpty) { if (mounted && allAlbums.isNotEmpty) {
// 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 libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList(); final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
// Check if data actually changed
final changed = _albums.length != libraryAlbums.length ||
_providerAlbums.length != providerOnlyAlbums.length;
if (changed) {
setState(() { setState(() {
_albums = libraryAlbums; _albums = libraryAlbums;
_providerAlbums = providerOnlyAlbums; _providerAlbums = providerOnlyAlbums;
@@ -728,133 +602,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
} }
} }
/// Toggle favorite status for an album
Future<void> _toggleAlbumFavorite(Album album) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !(album.favorite ?? false);
try {
bool success;
if (newState) {
// Add to favorites
String actualProvider = album.provider;
String actualItemId = album.itemId;
if (album.providerMappings != null && album.providerMappings!.isNotEmpty) {
final mapping = album.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => album.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => album.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// Remove from favorites
int? libraryItemId;
if (album.provider == 'library') {
libraryItemId = int.tryParse(album.itemId);
} else if (album.providerMappings != null) {
final libraryMapping = album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success) {
_loadArtistAlbums();
}
} catch (e) {
_logger.log('Error toggling album favorite: $e');
}
}
/// Toggle library status for an album
Future<void> _toggleAlbumLibrary(Album album) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !album.inLibrary;
try {
bool success;
if (newState) {
// Add to library
String? actualProvider;
String? actualItemId;
if (album.providerMappings != null && album.providerMappings!.isNotEmpty) {
final mapping = album.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => album.providerMappings!.first,
);
if (mapping.providerInstance != 'library') {
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
}
if (actualProvider != null && actualItemId != null) {
success = await maProvider.addToLibrary(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
success = false;
}
} else {
// Remove from library
int? libraryItemId;
if (album.provider == 'library') {
libraryItemId = int.tryParse(album.itemId);
} else if (album.providerMappings != null) {
final libraryMapping = album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success) {
_loadArtistAlbums();
}
} catch (e) {
_logger.log('Error toggling album library: $e');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds // CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
@@ -896,13 +643,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
backgroundColor: colorScheme.background, backgroundColor: colorScheme.background,
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 140-200 (smaller for circular artist image) // Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0); final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 45; final expandedHeight = coverSize + 100;
return Stack( return CustomScrollView(
children: [
CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: expandedHeight, expandedHeight: expandedHeight,
@@ -958,7 +703,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -975,7 +720,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
), ),
), ),
), ),
Spacing.vGap8, Spacing.vGap16,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[ if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell( InkWell(
onTap: () { onTap: () {
@@ -1003,10 +748,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
children: [ children: [
// Main Radio Button // Main Radio Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => _startRadio(context), onPressed: () => _showRadioMenu(context),
icon: const Icon(Icons.radio), icon: const Icon(Icons.radio),
label: Text(S.of(context)!.radio), label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -1022,6 +768,47 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
const SizedBox(width: 12), const SizedBox(width: 12),
// "Add to Queue" Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showAddToQueueMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 12),
// Favorite Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleFavorite,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Library Button // Library Button
SizedBox( SizedBox(
height: 50, height: 50,
@@ -1035,79 +822,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
), ),
), ),
child: Icon( child: Icon(
Symbols.book_2, _isInLibrary ? Icons.library_add_check : Icons.library_add,
size: 25, color: _isInLibrary
fill: isInLibrary ? 1 : 0,
color: isInLibrary
? colorScheme.primary ? colorScheme.primary
: Colors.white70, : colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Favorite Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleFavorite,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
Icons.favorite,
size: 25,
color: isFavorite
? colorScheme.error
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.artist,
item: widget.artist,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
viewMode: _viewMode,
onCycleView: _cycleViewMode,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
), ),
), ),
), ),
@@ -1137,18 +855,57 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
), ),
) )
else ...[ else ...[
// Library Albums Section // Library Albums Section with inline controls
if (_albums.isNotEmpty) ...[ if (_albums.isNotEmpty) ...[
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0), padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Text( child: Row(
children: [
Text(
S.of(context)!.inLibrary, S.of(context)!.inLibrary,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
),
), ),
), ),
_buildAlbumSliver(_albums), _buildAlbumSliver(_albums),
@@ -1158,14 +915,54 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
if (_providerAlbums.isNotEmpty) ...[ if (_providerAlbums.isNotEmpty) ...[
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB(24.0, _albums.isEmpty ? 8.0 : 24.0, 24.0, 8.0), padding: EdgeInsets.fromLTRB(24.0, _albums.isEmpty ? 8.0 : 24.0, 12.0, 8.0),
child: Text( child: Row(
children: [
Text(
S.of(context)!.fromProviders, S.of(context)!.fromProviders,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground, color: colorScheme.onBackground,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
// Only show controls here if no library albums
if (_albums.isEmpty) ...[
const Spacer(),
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
],
),
), ),
), ),
_buildAlbumSliver(_providerAlbums), _buildAlbumSliver(_providerAlbums),
@@ -1173,29 +970,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
], ],
], ],
], ],
),
// Bottom fade gradient to mask content behind mini player
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background,
],
),
),
),
),
),
],
); );
}, },
), ),
@@ -1211,28 +985,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
const String heroSuffix = 'artist_albums'; const String heroSuffix = 'artist_albums';
// Get adaptive scheme for context menu return InkWell(
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.album,
item: album,
isFavorite: album.favorite ?? false,
isInLibrary: album.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAlbumFavorite(album),
onToggleLibrary: () => _toggleAlbumLibrary(album),
);
},
child: InkWell(
onTap: () { onTap: () {
// Update adaptive colors immediately on tap // Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl); updateAdaptiveColorsFromImage(context, imageUrl);
@@ -1326,7 +1079,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
), ),
], ],
), ),
),
); );
} }
@@ -1369,28 +1121,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
// Get adaptive scheme for context menu return ListTile(
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.album,
item: album,
isFavorite: album.favorite ?? false,
isInLibrary: album.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAlbumFavorite(album),
onToggleLibrary: () => _toggleAlbumLibrary(album),
);
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: ClipRRect( leading: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -1445,7 +1176,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
), ),
); );
}, },
),
); );
} }
} }
+49 -272
View File
@@ -1,20 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/media_context_menu.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/metadata_service.dart'; import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart'; import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'audiobook_detail_screen.dart'; import 'audiobook_detail_screen.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
@@ -37,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
} }
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> { class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks; late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
@@ -121,149 +116,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
} }
} }
/// Toggle favorite status for an audiobook
Future<void> _toggleAudiobookFavorite(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !(book.favorite ?? false);
try {
bool success;
if (newState) {
// Add to favorites
String actualProvider = book.provider;
String actualItemId = book.itemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => book.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// Remove from favorites
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success && mounted) {
setState(() {
final index = _audiobooks.indexWhere((b) => b.itemId == book.itemId);
if (index != -1) {
_audiobooks[index] = Audiobook(
itemId: book.itemId,
provider: book.provider,
name: book.name,
sortName: book.sortName,
uri: book.uri,
providerMappings: book.providerMappings,
favorite: newState,
metadata: book.metadata,
year: book.year,
);
}
});
}
} catch (e) {
_logger.log('Error toggling audiobook favorite: $e');
}
}
/// Toggle library status for an audiobook
Future<void> _toggleAudiobookLibrary(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !book.inLibrary;
try {
bool success;
if (newState) {
// Add to library
String? actualProvider;
String? actualItemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.first,
);
if (mapping.providerInstance != 'library') {
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
}
if (actualProvider != null && actualItemId != null) {
success = await maProvider.addToLibrary(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
success = false;
}
} else {
// Remove from library
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success) {
// Refresh would require reloading from parent - for now just show success
_logger.log('Audiobook library status toggled');
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final adaptiveTheme = context.select<ThemeProvider, bool>( final adaptiveTheme = context.select<ThemeProvider, bool>(
@@ -298,13 +150,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 140-200 (matches artist detail) // Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0); final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 25; final expandedHeight = coverSize + 100;
return Stack( return CustomScrollView(
children: [
CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: expandedHeight, expandedHeight: expandedHeight,
@@ -370,12 +220,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0), padding: const EdgeInsets.all(24.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Author info on the left
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -396,86 +241,62 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
], ],
), ),
), ),
// Three-dot Menu Button on the right
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
isFavorite: false,
isInLibrary: false,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
viewMode: _viewMode,
onCycleView: _cycleViewMode,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
), // Audiobooks Section Header with controls
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
),
),
),
],
),
),
),
// Audiobooks Section Header
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0), padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Text( child: Row(
children: [
Text(
S.of(context)!.audiobooks, S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith( style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
), ),
), ),
_buildAudiobookSliver(), _buildAudiobookSliver(),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
], ],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
); );
}, },
), ),
@@ -523,28 +344,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}'; final heroSuffix = 'author${_heroTagSuffix}';
// Get adaptive scheme for context menu return InkWell(
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl), onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -640,7 +440,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
), ),
], ],
), ),
),
); );
} }
@@ -651,28 +450,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}'; final heroSuffix = 'author${_heroTagSuffix}';
// Get adaptive scheme for context menu return ListTile(
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero( leading: Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix', tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
@@ -756,7 +534,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
) )
: null, : null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl), onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
),
); );
} }
+29 -212
View File
@@ -1,14 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
@@ -38,7 +35,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final _logger = DebugLogger(); final _logger = DebugLogger();
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
bool _isFavorite = false; bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
int? _expandedChapterIndex; int? _expandedChapterIndex;
@@ -50,103 +46,76 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use full audiobook if loaded, otherwise use widget audiobook // Use full audiobook if loaded, otherwise use widget audiobook
Audiobook get _audiobook => _fullAudiobook ?? widget.audiobook; Audiobook get _audiobook => _fullAudiobook ?? widget.audiobook;
/// Check if audiobook is in library
bool _checkIfInLibrary(Audiobook audiobook) {
if (audiobook.provider == 'library') return true;
return audiobook.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.audiobook.favorite ?? false; _isFavorite = widget.audiobook.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.audiobook);
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer detail loading until after hero animation completes
// This prevents setState with new data during animation → jank
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () { Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) { if (mounted) {
_loadAudiobookDetails(); _extractColors();
} }
}); });
// Load full audiobook details with chapters
_loadAudiobookDetails();
}); });
} }
Future<void> _loadAudiobookDetails() async { Future<void> _loadAudiobookDetails() async {
if (_isLoadingDetails) return; if (_isLoadingDetails) return;
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(() { setState(() {
_isLoadingDetails = true; _isLoadingDetails = true;
}); });
}
// 2. Fetch fresh data in background
try { try {
final fullBook = await maProvider.getAudiobookDetailsWithCache( final maProvider = context.read<MusicAssistantProvider>();
if (maProvider.api != null) {
final fullBook = await maProvider.api!.getAudiobookDetails(
widget.audiobook.provider, widget.audiobook.provider,
widget.audiobook.itemId, widget.audiobook.itemId,
forceRefresh: cached != null,
); );
if (mounted && fullBook != null) { if (mounted && fullBook != null) {
_logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}'); _logger.log('📚 Loaded full audiobook: ${fullBook.name}, chapters: ${fullBook.chapters?.length ?? 0}');
setState(() { setState(() {
_fullAudiobook = fullBook; _fullAudiobook = fullBook;
});
}
}
if (mounted) {
setState(() {
_isLoadingDetails = false; _isLoadingDetails = false;
}); });
} }
} catch (e) { } catch (e) {
_logger.log('Error loading audiobook details: $e'); _logger.log('Error loading audiobook details: $e');
} if (mounted) {
if (mounted && _isLoadingDetails) {
setState(() { setState(() {
_isLoadingDetails = false; _isLoadingDetails = false;
}); });
} }
} }
}
Future<void> _extractColors() async { Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = maProvider.getImageUrl(_audiobook, size: 512); final imageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
if (imageUrl == null) return; if (imageUrl == null) return;
try { try {
// Use isolate-based extraction to avoid blocking the main thread final colorSchemes = await PaletteHelper.extractColorSchemes(
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl); CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) { if (colorSchemes != null && mounted) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _darkColorScheme = colorSchemes.$2;
}); });
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
} }
} catch (e) { } catch (e) {
_logger.log('Failed to extract colors for audiobook: $e'); _logger.log('Failed to extract colors for audiobook: $e');
@@ -240,88 +209,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
} }
} }
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
try {
final newState = !_isInLibrary;
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.audiobook.providerMappings != null && widget.audiobook.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.audiobook.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (widget.audiobook.provider != 'library') {
actualProvider = widget.audiobook.provider;
actualItemId = widget.audiobook.itemId;
} else {
return;
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.addToLibrary(
mediaType: 'audiobook',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
} else {
int? libraryItemId;
if (widget.audiobook.provider == 'library') {
libraryItemId = int.tryParse(widget.audiobook.itemId);
} else if (widget.audiobook.providerMappings != null) {
final libraryMapping = widget.audiobook.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.audiobook.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
void _showPlayOnMenu(BuildContext context) { void _showPlayOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -497,7 +384,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final providerImageUrl = maProvider.getImageUrl(_audiobook, size: 512); final providerImageUrl = maProvider.getImageUrl(widget.audiobook, size: 512);
// Use initialImageUrl as fallback for seamless hero animation // Use initialImageUrl as fallback for seamless hero animation
final imageUrl = providerImageUrl ?? widget.initialImageUrl; final imageUrl = providerImageUrl ?? widget.initialImageUrl;
@@ -540,9 +427,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70; final expandedHeight = coverSize + 70;
return Stack( return CustomScrollView(
children: [
CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: expandedHeight, expandedHeight: expandedHeight,
@@ -671,7 +556,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -767,6 +652,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [ children: [
// Play/Resume Button // Play/Resume Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -800,33 +686,26 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: const Icon(Icons.replay, size: 25), child: const Icon(Icons.replay),
), ),
), ),
], ],
const SizedBox(width: 12), const SizedBox(width: 12),
// Library Button // Play On Button
SizedBox( SizedBox(
height: 50, height: 50,
width: 50, width: 50,
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: _toggleLibrary, onPressed: () => _showPlayOnMenu(context),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: Icon( child: const Icon(Icons.speaker_group_outlined),
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: Colors.white70,
),
), ),
), ),
@@ -845,50 +724,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
), ),
), ),
child: Icon( child: Icon(
Icons.favorite, _isFavorite ? Icons.favorite : Icons.favorite_border,
size: 25,
color: _isFavorite color: _isFavorite
? colorScheme.error ? colorScheme.error
: Colors.white70, : colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: widget.audiobook,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
), ),
), ),
), ),
@@ -948,28 +787,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
], ],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
); );
}, },
), ),
+44 -105
View File
@@ -44,31 +44,25 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
// Extracted colors from book covers // Extracted colors from book covers
List<Color> _extractedColors = []; List<Color> _extractedColors = [];
// Cover URLs - set once from initialCovers or first book load, then frozen // Cover URLs - start with initial covers, update when books load
List<String> _coverUrls = []; List<String> _coverUrls = [];
// Cached cover widget - built once, never rebuilt
Widget? _cachedCoverWidget;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Use initial covers immediately for smooth Hero animation // Use initial covers immediately for smooth Hero animation
if (widget.initialCovers != null && widget.initialCovers!.isNotEmpty) { if (widget.initialCovers != null && widget.initialCovers!.isNotEmpty) {
_coverUrls = List.from(widget.initialCovers!); _coverUrls = List.from(widget.initialCovers!);
// Start color extraction from initial covers
_extractColorsFromUrls(widget.initialCovers!);
} }
_loadViewPreferences(); _loadViewPreferences();
// Defer color extraction and loading until after hero animation completes // Defer loading until after first frame to ensure UI renders first
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) { if (mounted) {
if (_coverUrls.isNotEmpty) {
_extractColorsFromUrls(_coverUrls);
}
_loadSeriesBooks(); _loadSeriesBooks();
} }
}); });
});
} }
/// Extract colors from cover URLs (used for initial covers) /// Extract colors from cover URLs (used for initial covers)
@@ -164,68 +158,35 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Future<void> _loadSeriesBooks() async { Future<void> _loadSeriesBooks() async {
_logger.log('📚 SeriesScreen _loadSeriesBooks START'); _logger.log('📚 SeriesScreen _loadSeriesBooks START');
final maProvider = context.read<MusicAssistantProvider>(); try {
// 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(() { setState(() {
_isLoading = true; _isLoading = true;
_error = null; _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) { if (maProvider.api == null) {
if (cached == null || cached.isEmpty) {
setState(() { setState(() {
_error = S.of(context)!.notConnected; _error = S.of(context)!.notConnected;
_isLoading = false; _isLoading = false;
}); });
}
return; return;
} }
final books = await maProvider.getSeriesAudiobooksWithCache( _logger.log('📚 SeriesScreen calling getSeriesAudiobooks: path=${widget.series.id}');
widget.series.id, final books = await maProvider.api!.getSeriesAudiobooks(widget.series.id);
forceRefresh: cached != null && cached.isNotEmpty,
);
_logger.log('📚 SeriesScreen got ${books.length} books'); _logger.log('📚 SeriesScreen got ${books.length} books');
if (mounted && books.isNotEmpty) { // Debug: Log sequence data for each book
final changed = _audiobooks.length != books.length; for (final book in books) {
if (changed || _audiobooks.isEmpty) { _logger.log('📚 Book: "${book.name}" | position=${book.position} | browseOrder=${book.browseOrder} | sortName=${book.sortName} | seq=${book.seriesSequence} | metadata.series=${book.metadata?['series']}');
_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 && _isLoading) { if (mounted) {
setState(() => _isLoading = false); // Update cover URLs from loaded books
}
}
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>[]; final newCovers = <String>[];
for (final book in books.take(9)) { for (final book in books.take(9)) {
final imageUrl = maProvider.getImageUrl(book); final imageUrl = maProvider.getImageUrl(book);
@@ -233,16 +194,35 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
newCovers.add(imageUrl); 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) { if (newCovers.isNotEmpty) {
_coverUrls = newCovers; _coverUrls = newCovers;
} }
}
_isLoading = false; _isLoading = false;
}); });
// Debug: Log sorted order
_logger.log('📚 Sorted order ($_sortOrder):');
for (final book in _audiobooks) {
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
}
if (needsCovers && _extractedColors.isEmpty) { // Extract colors from book covers asynchronously
_extractCoverColors(maProvider); _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;
});
}
}
} }
/// Extract colors from book covers for empty grid cells /// Extract colors from book covers for empty grid cells
@@ -291,8 +271,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
// Use read since we only need the provider for non-reactive operations (getImageUrl) final maProvider = context.watch<MusicAssistantProvider>();
final maProvider = context.read<MusicAssistantProvider>();
final l10n = S.of(context)!; final l10n = S.of(context)!;
return Scaffold( return Scaffold(
@@ -303,9 +282,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70; final expandedHeight = coverSize + 70;
return Stack( return CustomScrollView(
children: [
CustomScrollView(
slivers: [ slivers: [
// App bar with series cover collage (matches audiobook detail screen) // App bar with series cover collage (matches audiobook detail screen)
SliverAppBar( SliverAppBar(
@@ -323,11 +300,13 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
children: [ children: [
const SizedBox(height: 60), const SizedBox(height: 60),
// Series cover collage - responsive size // Series cover collage - responsive size
// Shadow container outside Hero to avoid widget tree mismatch Hero(
Container( tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@@ -337,20 +316,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
), ),
], ],
), ),
// 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( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: _buildSeriesCover(colorScheme, maProvider), child: _buildSeriesCover(colorScheme, maProvider),
), ),
), ),
), ),
),
),
], ],
), ),
), ),
@@ -485,28 +456,6 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
child: SizedBox(height: BottomSpacing.withMiniPlayer), child: SizedBox(height: BottomSpacing.withMiniPlayer),
), ),
], ],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
); );
}, },
), ),
@@ -541,9 +490,6 @@ 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; final covers = _coverUrls;
// Dynamic grid size based on number of covers (matches library screen) // Dynamic grid size based on number of covers (matches library screen)
@@ -567,8 +513,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
} }
// Build grid using Column/Row for proper sizing (no scrolling issues) // Build grid using Column/Row for proper sizing (no scrolling issues)
// Cache the widget so it never rebuilds from unrelated setState calls return Column(
_cachedCoverWidget = Column(
children: List.generate(gridSize, (row) { children: List.generate(gridSize, (row) {
return Expanded( return Expanded(
child: Row( child: Row(
@@ -579,11 +524,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
return Expanded( return Expanded(
child: coverUrl != null child: coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
key: ValueKey(coverUrl),
imageUrl: coverUrl, imageUrl: coverUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container( placeholder: (_, __) => Container(
@@ -605,7 +547,6 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
); );
}), }),
); );
return _cachedCoverWidget!;
} }
/// Builds an empty cell with either a solid color or a nested grid /// Builds an empty cell with either a solid color or a nested grid
@@ -723,8 +664,6 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
fit: BoxFit.cover, fit: BoxFit.cover,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center( placeholder: (_, __) => Center(
+6 -2
View File
@@ -41,10 +41,12 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList(); _logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
void _copyLogs() { void _copyLogs() {
Clipboard.setData(ClipboardData(text: _logger.getAllLogs())); // Copy only the filtered entries that are currently displayed
final filteredLogs = _filteredEntries.map((e) => e.formatted).join('\n');
Clipboard.setData(ClipboardData(text: filteredLogs));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(S.of(context)!.logsCopied), content: Text('${S.of(context)!.logsCopied} (${_filteredEntries.length} entries)'),
backgroundColor: Colors.green, backgroundColor: Colors.green,
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
@@ -61,6 +63,8 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
report, report,
subject: S.of(context)!.ensembleBugReport, subject: S.of(context)!.ensembleBugReport,
); );
} catch (e) {
_logger.error('Share failed', error: e);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _isGeneratingReport = false); setState(() => _isGeneratingReport = false);
+1 -171
View File
@@ -1,11 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import 'new_home_screen.dart'; import 'new_home_screen.dart';
import 'new_library_screen.dart'; import 'new_library_screen.dart';
@@ -22,7 +19,6 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
final GlobalKey<SearchScreenState> _searchScreenKey = GlobalKey<SearchScreenState>(); final GlobalKey<SearchScreenState> _searchScreenKey = GlobalKey<SearchScreenState>();
DateTime? _lastBackPress; DateTime? _lastBackPress;
final _cachedNavColor = _CachedNavColor();
@override @override
void initState() { void initState() {
@@ -109,15 +105,7 @@ class _HomeScreenState extends State<HomeScreen> {
}, },
child: Scaffold( child: Scaffold(
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
// extendBody: true preserves the existing layout where content fills the // No bottomNavigationBar here - it's now in GlobalPlayerOverlay
// full screen and screens manage their own bottom padding (BottomSpacing).
// Without this, Scaffold would constrain the body above the nav bar,
// causing double bottom spacing.
extendBody: true,
// BottomNavigationBar requires an Overlay ancestor (for Tooltip);
// Scaffold inside Navigator provides one. Previously it was in
// GlobalPlayerOverlay's Stack (outside Navigator) causing crashes.
bottomNavigationBar: _buildBottomNavigationBar(context, colorScheme),
body: Stack( body: Stack(
children: [ children: [
// Home and Library use IndexedStack for state preservation // Home and Library use IndexedStack for state preservation
@@ -143,162 +131,4 @@ class _HomeScreenState extends State<HomeScreen> {
}, },
); );
} }
/// Build the bottom navigation bar with adaptive colors and player expansion animation.
/// Moved here from GlobalPlayerOverlay so it has proper Overlay ancestry
/// (required by BottomNavigationBar's internal Tooltip widgets).
Widget? _buildBottomNavigationBar(BuildContext context, ColorScheme colorScheme) {
// Hide nav bar when not connected and no cached players
final showNavBar = context.select<MusicAssistantProvider, bool>(
(p) => p.isConnected || p.hasCachedPlayers,
);
if (!showNavBar) return null;
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
builder: (context, expansionState, _) {
// Nav bar color logic - only use adaptive colors when:
// 1. Player is expanding/expanded, OR
// 2. On a detail screen (isOnDetailScreen)
// On home screen with collapsed player: always use default theme colors
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
// Nav bar background color
final Color navBgColor;
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
// Player is expanding - blend from surface to player's adaptive color
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
} else if (useAdaptiveColors) {
// On a detail screen - use adaptive surface color for nav bar
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
navBgColor = adaptiveBg ?? colorScheme.surface;
} else {
// Home screen with collapsed player - use default surface color
navBgColor = colorScheme.surface;
}
// Icon color - only use adaptive colors when appropriate
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
? themeProvider.adaptiveColors!.primary
: colorScheme.primary;
// Blend icon color with player's primary color during expansion
Color sourceColor = baseSourceColor;
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Fade out and slide down nav bar as player expands
// Use IgnorePointer when faded to prevent accidental taps
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
return IgnorePointer(
ignoring: navOpacity < 0.1,
child: Transform.translate(
offset: Offset(0, navSlideDown),
child: Opacity(
opacity: navOpacity,
child: Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
// Clear adaptive colors when switching tabs (detail screen colors shouldn't persist)
themeProvider.clearAdaptiveColors();
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Symbols.book_2, fill: 0),
activeIcon: const Icon(Symbols.book_2, fill: 1),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
),
),
),
);
},
);
},
);
}
}
/// Cached color with contrast adjustment for nav bar icon colors.
/// Avoids expensive HSL conversions during scroll/animation.
class _CachedNavColor {
Color? _sourceColor;
bool? _isDark;
Color? _adjustedColor;
Color getAdjustedColor(Color sourceColor, bool isDark) {
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
return _adjustedColor!;
}
var navSelectedColor = sourceColor;
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
_sourceColor = sourceColor;
_isDark = isDark;
_adjustedColor = navSelectedColor;
return navSelectedColor;
}
} }
+3 -4
View File
@@ -43,6 +43,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -65,13 +66,11 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
), ),
centerTitle: true, centerTitle: true,
), ),
body: _buildPlaylistsList(context), body: _buildPlaylistsList(context, provider),
); );
} }
Widget _buildPlaylistsList(BuildContext context) { Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
// Use read since we only need the provider for non-reactive operations
final provider = context.read<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading // Show cached data immediately if available, even while loading
+11 -16
View File
@@ -11,6 +11,7 @@ class LibraryTracksScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -33,41 +34,35 @@ class LibraryTracksScreen extends StatelessWidget {
), ),
centerTitle: true, centerTitle: true,
), ),
body: _buildTracksList(context), body: _buildTracksList(context, provider),
); );
} }
Widget _buildTracksList(BuildContext context) { Widget _buildTracksList(
// Use select to only rebuild when tracks or isLoading changes BuildContext context, MusicAssistantProvider provider) {
final (tracks, isLoading) = context.select<MusicAssistantProvider, (List, bool)>(
(p) => (p.tracks, p.isLoading),
);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading // Show cached data immediately if available, even while loading
// Only show spinner if we have no data at all AND we're loading // Only show spinner if we have no data at all AND we're loading
if (tracks.isEmpty && isLoading) { if (provider.tracks.isEmpty && provider.isLoading) {
return Center( return Center(
child: CircularProgressIndicator(color: colorScheme.primary), child: CircularProgressIndicator(color: colorScheme.primary),
); );
} }
if (tracks.isEmpty) { if (provider.tracks.isEmpty) {
return EmptyState.tracks( return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
context: context,
onRefresh: context.read<MusicAssistantProvider>().loadLibrary,
);
} }
return ListView.builder( return ListView.builder(
itemCount: tracks.length, itemCount: provider.tracks.length,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = tracks[index] as Track; final track = provider.tracks[index];
return _buildTrackTile(context, track, index); return _buildTrackTile(context, track, provider, index);
}, },
); );
} }
@@ -75,9 +70,9 @@ class LibraryTracksScreen extends StatelessWidget {
Widget _buildTrackTile( Widget _buildTrackTile(
BuildContext context, BuildContext context,
Track track, Track track,
MusicAssistantProvider maProvider,
int index, int index,
) { ) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = track.album != null final imageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 128) ? maProvider.getImageUrl(track.album!, size: 128)
: null; : null;
File diff suppressed because it is too large Load Diff
+53 -265
View File
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/sync_service.dart'; import '../services/sync_service.dart';
@@ -18,7 +17,6 @@ import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart'; import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart'; import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart'; import '../widgets/podcast_row.dart';
import '../widgets/discovery_row.dart';
import '../widgets/common/disconnected_state.dart'; import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
@@ -30,9 +28,9 @@ class NewHomeScreen extends StatefulWidget {
State<NewHomeScreen> createState() => _NewHomeScreenState(); State<NewHomeScreen> createState() => _NewHomeScreenState();
} }
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin { class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
static final _logger = DebugLogger(); static final _logger = DebugLogger();
final ValueNotifier<int> _refreshSignal = ValueNotifier<int>(0); Key _refreshKey = UniqueKey();
// Main rows (default on) // Main rows (default on)
bool _showRecentAlbums = true; bool _showRecentAlbums = true;
bool _showDiscoverArtists = true; bool _showDiscoverArtists = true;
@@ -50,10 +48,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showDiscoverSeries = false; bool _showDiscoverSeries = false;
// Row order (loaded from settings) // Row order (loaded from settings)
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder); List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Discovery folders (dynamic rows from provider recommendations)
List<RecommendationFolder> _discoveryFolders = [];
// Discovery row preferences (itemId -> enabled)
Map<String, bool> _discoveryRowEnabled = {};
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -63,82 +57,25 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
SyncStatus? _lastSyncStatus; SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0; int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo; MusicAssistantProvider? _providerListeningTo;
int _lastHomeRefreshCounter = 0; // Track home refresh counter to force row refreshes
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_loadSettings(); _loadSettings();
// Listen to sync completion to refresh favorite rows when data becomes available // Listen to sync completion to refresh favorite rows when data becomes available
SyncService.instance.addListener(_onSyncChanged); SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status; _lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState(); _checkInitialFavoriteState();
// Always listen to provider for home refresh counter changes
final provider = context.read<MusicAssistantProvider>();
_lastHomeRefreshCounter = provider.homeRefreshCounter;
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
// Reload settings only when switching back to home from settings tab
navigationProvider.addListener(_onNavigationChanged);
}
int _lastNavIndex = 0;
void _onNavigationChanged() {
final newIndex = navigationProvider.selectedIndex;
// Reload preferences when leaving settings tab (3) for home tab (0)
if (_lastNavIndex == 3 && newIndex == 0) {
_reloadPreferencesOnly();
}
_lastNavIndex = newIndex;
}
/// Reload only local preferences (no API calls). Used when switching back from settings.
Future<void> _reloadPreferencesOnly() async {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
} }
@override @override
void dispose() { void dispose() {
navigationProvider.removeListener(_onNavigationChanged);
SyncService.instance.removeListener(_onSyncChanged); SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener // Clean up provider listener if attached
_providerListeningTo?.removeListener(_onProviderChanged); _providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null; _providerListeningTo = null;
_refreshSignal.dispose(); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@@ -160,32 +97,22 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}); });
} }
/// Called when MusicAssistantProvider changes (for cache load detection and home refresh) /// Called when MusicAssistantProvider changes (for cache load detection)
void _onProviderChanged() { void _onProviderChanged() {
if (!mounted || _providerListeningTo == null) return; if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
final currentCount = _providerListeningTo!.artists.length;
final provider = _providerListeningTo!; // If artists went from 0 to non-zero, refresh
final currentRefreshCounter = provider.homeRefreshCounter;
// Check if home refresh counter changed (indicates authentication completed and cache was invalidated)
if (currentRefreshCounter != _lastHomeRefreshCounter) {
_logger.log('🔄 Home: refresh counter changed ($_lastHomeRefreshCounter -> $currentRefreshCounter), refreshing all rows');
_lastHomeRefreshCounter = currentRefreshCounter;
_refreshSignal.value++;
return; // Don't process other refresh conditions in this cycle
}
// Check for artists cache load (when favorites were empty on load)
if (_hadEmptyFavoritesOnLoad) {
final currentCount = provider.artists.length;
if (_lastArtistCount == 0 && currentCount > 0) { if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows'); _logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false; _hadEmptyFavoritesOnLoad = false;
_refreshSignal.value++; _providerListeningTo!.removeListener(_onProviderChanged);
_providerListeningTo = null;
setState(() {
_refreshKey = UniqueKey();
});
} }
_lastArtistCount = currentCount; _lastArtistCount = currentCount;
} }
}
/// Called when SyncService status changes /// Called when SyncService status changes
void _onSyncChanged() { void _onSyncChanged() {
@@ -200,12 +127,21 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_providerListeningTo?.removeListener(_onProviderChanged); _providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null; _providerListeningTo = null;
if (mounted) { if (mounted) {
_refreshSignal.value++; setState(() {
_refreshKey = UniqueKey();
});
} }
} }
_lastSyncStatus = newStatus; _lastSyncStatus = newStatus;
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
if (state == AppLifecycleState.resumed) {
_loadSettings();
}
}
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final showRecent = await SettingsService.getShowRecentAlbums(); final showRecent = await SettingsService.getShowRecentAlbums();
@@ -221,7 +157,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks(); final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries(); final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder(); final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) { if (mounted) {
setState(() { setState(() {
_showRecentAlbums = showRecent; _showRecentAlbums = showRecent;
@@ -237,63 +172,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries; _showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder; _homeRowOrder = rowOrder;
_discoveryRowEnabled = discoveryRowPrefs;
}); });
} }
// Load discovery folders dynamically (after settings load)
// Skip if already loaded to avoid unnecessary rebuilds on settings reload
if (_discoveryFolders.isEmpty) {
await _loadDiscoveryFolders(forceRefresh: false);
} else {
// Just re-sync the row order with current enabled state (no API call)
setState(() {
_syncDiscoveryRowOrder(_discoveryFolders);
});
}
}
Future<void> _loadDiscoveryFolders({bool forceRefresh = false}) async {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
try {
var folders = await provider.getDiscoveryFoldersWithCache();
// If cached result is empty, force a refresh to get from API/database
if (folders.isEmpty && !forceRefresh) {
_logger.log('🔄 Discovery: Cached folders empty, forcing refresh...');
folders = await provider.getDiscoveryFoldersWithCache(forceRefresh: true);
}
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_syncDiscoveryRowOrder(folders);
});
}
} catch (e) {
_logger.log('⚠️ Failed to load discovery folders: $e');
}
}
/// Sync discovery row order without calling setState. Only adds new folders, never removes.
/// Visibility is controlled by _discoveryRowEnabled / _isRowEnabled().
bool _syncDiscoveryRowOrder(List<RecommendationFolder> folders) {
bool didModify = false;
for (final folder in folders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
_homeRowOrder.add(discoveryRowId);
didModify = true;
}
}
if (didModify) {
SettingsService.setHomeRowOrder(_homeRowOrder);
}
return didModify;
} }
Future<void> _onRefresh() async { Future<void> _onRefresh() async {
@@ -308,7 +188,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
await _loadSettings(); await _loadSettings();
if (mounted) { if (mounted) {
_refreshSignal.value++; setState(() {
_refreshKey = UniqueKey();
});
} }
} }
@@ -345,6 +227,21 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
titleSpacing: 0, titleSpacing: 0,
centerTitle: false, centerTitle: false,
actions: [ actions: [
// Copy logs button for debugging
IconButton(
icon: const Icon(Icons.bug_report_outlined, size: 20),
onPressed: () {
final logs = DebugLogger().entries.map((e) => e.formatted).join('\n');
Clipboard.setData(ClipboardData(text: logs));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${DebugLogger().entries.length} log entries copied'),
duration: const Duration(seconds: 2),
),
);
},
tooltip: 'Copy logs',
),
// Sync indicator - shows when library is syncing in background // Sync indicator - shows when library is syncing in background
ListenableBuilder( ListenableBuilder(
listenable: SyncService.instance, listenable: SyncService.instance,
@@ -430,29 +327,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
), ),
), ),
), ),
// Bottom fade gradient to mask content behind mini player
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.miniPlayerHeight + 22.0,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.background.withOpacity(0.0),
colorScheme.background.withOpacity(0.25),
colorScheme.background.withOpacity(0.85),
],
stops: const [0.0, 0.5, 1.0],
),
),
),
),
),
], ],
); );
}, },
@@ -473,12 +347,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
/// Check if a specific row is enabled /// Check if a specific row is enabled
bool _isRowEnabled(String rowId) { bool _isRowEnabled(String rowId) {
// Discovery folders use the pattern 'discovery:{itemId}'
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
// Default to true if not explicitly set
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) { switch (rowId) {
case 'recent-albums': return _showRecentAlbums; case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists; case 'discover-artists': return _showDiscoverArtists;
@@ -503,18 +371,15 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
builder: (context, constraints) { builder: (context, constraints) {
// Each row is always 1/3 of screen height // Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll // 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. final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
// Subtract mini player overlay space (mini player + its margins sit above the nav bar).
final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0; // 84 + 22 = 106
final availableHeight = constraints.maxHeight - miniPlayerSpace;
// Account for margins between rows (2px each, 2 margins for 3 rows). // Account for margins between rows (2px each)
// Always use the 3-row margin calculation so rowHeight stays stable // Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
// regardless of how many rows are currently enabled (avoids jitter on reload). // For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
const marginSize = 2.0; const marginSize = 2.0;
final enabledRows = _countEnabledRows(); final enabledRows = _countEnabledRows();
const marginsInView = 2 * marginSize; // always 2 margins for 3-row layout final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
final rowHeight = (availableHeight - marginsInView) / 3 + 2; final rowHeight = (availableHeight - marginsInView) / 3;
// Use Android 12+ stretch overscroll effect // Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
@@ -536,18 +401,11 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
behavior: const _StretchScrollBehavior(), behavior: const _StretchScrollBehavior(),
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
// For ≤3 rows: no padding needed (rows fit within viewport) padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
// For 4+ rows: pad so last row scrolls above mini player
padding: enabledRows >= 4
? EdgeInsets.only(bottom: miniPlayerSpace)
: EdgeInsets.zero,
child: SizedBox(
// Ensure minimum height for pull-to-refresh when empty
height: enabledRows == 0 ? availableHeight : null,
child: Column( child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight, enabledRows == 0), children: _buildOrderedRows(provider, rowHeight),
),
), ),
), ),
), ),
@@ -557,51 +415,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
} }
/// Build rows in the user's configured order /// Build rows in the user's configured order
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight, bool isEmpty) { List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
final rows = <Widget>[]; final rows = <Widget>[];
// Show empty state with refresh hint when no rows are enabled
if (isEmpty) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
rows.add(
SizedBox.expand(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_rounded,
size: 64,
color: colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
S.of(context)!.noRowsEnabled,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
S.of(context)!.pullToRefreshHint,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.4),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
);
return rows;
}
for (final rowId in _homeRowOrder) { for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight); final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) { if (widget != null) {
@@ -626,7 +442,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.recentlyPlayed, title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(), loadAlbums: () => provider.getRecentAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedRecentAlbums(), getCachedAlbums: () => provider.getCachedRecentAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight, rowHeight: rowHeight,
); );
case 'discover-artists': case 'discover-artists':
@@ -636,7 +451,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.discoverArtists, title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(), loadArtists: () => provider.getDiscoverArtistsWithCache(),
getCachedArtists: () => provider.getCachedDiscoverArtists(), getCachedArtists: () => provider.getCachedDiscoverArtists(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight, rowHeight: rowHeight,
); );
case 'discover-albums': case 'discover-albums':
@@ -646,7 +460,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.discoverAlbums, title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(), loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedDiscoverAlbums(), getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight, rowHeight: rowHeight,
); );
case 'continue-listening': case 'continue-listening':
@@ -682,7 +495,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('favorite-albums'), key: const ValueKey('favorite-albums'),
title: S.of(context)!.favoriteAlbums, title: S.of(context)!.favoriteAlbums,
loadAlbums: () => provider.getFavoriteAlbums(), loadAlbums: () => provider.getFavoriteAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight, rowHeight: rowHeight,
); );
case 'favorite-artists': case 'favorite-artists':
@@ -691,7 +503,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('favorite-artists'), key: const ValueKey('favorite-artists'),
title: S.of(context)!.favoriteArtists, title: S.of(context)!.favoriteArtists,
loadArtists: () => provider.getFavoriteArtists(), loadArtists: () => provider.getFavoriteArtists(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight, rowHeight: rowHeight,
); );
case 'favorite-tracks': case 'favorite-tracks':
@@ -730,29 +541,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
rowHeight: rowHeight, rowHeight: rowHeight,
); );
default: default:
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final folderId = rowId.substring('discovery:'.length);
// Check per-row preference
final isEnabled = _discoveryRowEnabled[folderId] ?? false;
if (!isEnabled) return null;
try {
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == folderId,
);
return DiscoveryRow(
key: ValueKey(rowId),
title: folder.name,
loadItems: () => provider.getDiscoveryFolderItems(folderId),
getCachedItems: () => provider.getCachedDiscoveryFolderItems(folderId),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
} catch (e) {
// Folder not found, skip this row
}
}
return null; return null;
} }
} }
File diff suppressed because it is too large Load Diff
+235 -358
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
@@ -11,8 +10,6 @@ import '../services/debug_logger.dart';
import '../services/recently_played_service.dart'; import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
@@ -44,9 +41,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
List<Track> _tracks = []; List<Track> _tracks = [];
bool _isLoading = true; bool _isLoading = true;
bool _isFavorite = false; bool _isFavorite = false;
late bool _isInLibrary;
ColorScheme? _lightColorScheme; ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme; ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -58,21 +55,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void initState() { void initState() {
super.initState(); super.initState();
_isFavorite = widget.playlist.favorite ?? false; _isFavorite = widget.playlist.favorite ?? false;
_isInLibrary = widget.playlist.inLibrary; _loadTracks();
// Mark that we're on a detail screen and extract colors immediately // Defer color extraction until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer track loading until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () { Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) { if (mounted) {
_loadTracks(); _extractColors();
} }
}); });
}); });
@@ -85,18 +74,15 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
if (imageUrl == null) return; if (imageUrl == null) return;
try { try {
// Use isolate-based extraction to avoid blocking the main thread final colorSchemes = await PaletteHelper.extractColorSchemes(
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl); CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) { if (colorSchemes != null && mounted) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _darkColorScheme = colorSchemes.$2;
}); });
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
} }
} catch (e) { } catch (e) {
_logger.log('Failed to extract colors for playlist: $e'); _logger.log('Failed to extract colors for playlist: $e');
@@ -241,86 +227,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
} }
} }
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.playlist.providerMappings != null && widget.playlist.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.playlist.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (widget.playlist.provider != 'library') {
actualProvider = widget.playlist.provider;
actualItemId = widget.playlist.itemId;
} else {
return;
}
}
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.addToLibrary(
mediaType: 'playlist',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
} else {
int? libraryItemId;
if (widget.playlist.provider == 'library') {
libraryItemId = int.tryParse(widget.playlist.itemId);
} else if (widget.playlist.providerMappings != null) {
final libraryMapping = widget.playlist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.playlist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
setState(() => _isInLibrary = newState);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
maProvider.removeFromLibrary(
mediaType: 'playlist',
libraryItemId: libraryItemId,
).catchError((e) {
if (mounted) setState(() => _isInLibrary = !newState);
});
}
} catch (e) {
// Silent failure
}
}
Future<void> _toggleTrackFavorite(int trackIndex) async { Future<void> _toggleTrackFavorite(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return; if (trackIndex < 0 || trackIndex >= _tracks.length) return;
@@ -418,89 +324,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
} }
} }
Future<void> _toggleTrackLibrary(int trackIndex) async {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
final track = _tracks[trackIndex];
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = track.inLibrary;
try {
if (!currentInLibrary) {
// Add to library
String? actualProvider;
String? actualItemId;
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
final nonLibraryMapping = track.providerMappings!.where(
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
).firstOrNull;
if (nonLibraryMapping != null) {
actualProvider = nonLibraryMapping.providerDomain;
actualItemId = nonLibraryMapping.itemId;
}
}
if (actualProvider == null || actualItemId == null) {
if (track.provider != 'library') {
actualProvider = track.provider;
actualItemId = track.itemId;
} else {
return;
}
}
await maProvider.addToLibrary(
mediaType: 'track',
provider: actualProvider,
itemId: actualItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
} else {
// Remove from library
int? libraryItemId;
if (track.provider == 'library') {
libraryItemId = int.tryParse(track.itemId);
} else if (track.providerMappings != null) {
final libraryMapping = track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
await maProvider.removeFromLibrary(
mediaType: 'track',
libraryItemId: libraryItemId,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
}
} catch (e) {
_logger.log('Error toggling track library: $e');
}
}
Future<void> _playPlaylist() async { Future<void> _playPlaylist() async {
if (_tracks.isEmpty) return; if (_tracks.isEmpty) return;
@@ -564,86 +387,56 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
); );
} }
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) { void _addPlaylistToQueue() {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: colorScheme.surface, backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder( builder: (context) => Container(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (context) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 8), Spacing.vGap16,
Container( Text(
width: 32, S.of(context)!.addToQueueOn,
height: 4, style: Theme.of(context).textTheme.titleLarge,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
), ),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
), ),
const SizedBox(height: 16), title: Text(player.name),
ListTile( onTap: () async {
leading: Icon(Icons.speaker_group_outlined, color: colorScheme.onSurface),
title: Text(S.of(context)!.playOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showPlayOnMenu(context);
},
),
ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
// Add to queue on selected player
final player = maProvider.selectedPlayer;
if (player != null) {
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
});
} else {
_showError(S.of(context)!.noPlayerSelected);
}
},
),
ListTile(
leading: Icon(Icons.queue_music, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueueOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_addPlaylistToQueue();
},
),
const SizedBox(height: 8),
],
),
),
);
}
void _addPlaylistToQueue() {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try { try {
_logger.log('Adding playlist to queue on ${player.name}'); _logger.log('Adding playlist to queue on ${player.name}');
await maProvider.addTracksToQueue( await maProvider.playTracks(
player.playerId, player.playerId,
_tracks, _tracks,
startIndex: 0,
clearQueue: false,
); );
_logger.log('Playlist added to queue on ${player.name}'); _logger.log('Playlist added to queue on ${player.name}');
if (mounted) { if (mounted) {
@@ -660,19 +453,67 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
} }
}, },
); );
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
} }
void _addTrackToQueue(BuildContext context, int index) { void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.showPlayerSelectorForAction( GlobalPlayerOverlay.hidePlayer();
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async { showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
Navigator.pop(context);
try { try {
await maProvider.addTracksToQueue( await maProvider.playTracks(
player.playerId, player.playerId,
_tracks, _tracks,
startIndex: index, startIndex: index,
clearQueue: false,
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -688,6 +529,16 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
} }
}, },
); );
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
} }
void _showPlayRadioMenu(BuildContext context, int trackIndex) { void _showPlayRadioMenu(BuildContext context, int trackIndex) {
@@ -805,9 +656,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70; final expandedHeight = coverSize + 70;
return Stack( return CustomScrollView(
children: [
CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: expandedHeight, expandedHeight: expandedHeight,
@@ -892,7 +741,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0), padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -933,6 +782,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
children: [ children: [
// Main Play Button // Main Play Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -952,6 +802,42 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// "Add to Queue" Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : _addPlaylistToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 12),
// Favorite Button // Favorite Button
SizedBox( SizedBox(
height: 50, height: 50,
@@ -965,53 +851,10 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
), ),
child: Icon( child: Icon(
Icons.favorite, _isFavorite ? Icons.favorite : Icons.favorite_border,
size: 25,
color: _isFavorite color: _isFavorite
? colorScheme.error ? colorScheme.error
: Colors.white70, : colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
if (_isLoading || _tracks.isEmpty) return;
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.playlist,
item: widget.playlist,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: _isLoading || _tracks.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
), ),
), ),
), ),
@@ -1045,30 +888,19 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
final track = _tracks[index]; final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
final trackImageUrl = context.read<MusicAssistantProvider>().getImageUrl(track, size: 80); final trackImageUrl = context.read<MusicAssistantProvider>().getImageUrl(track, size: 80);
return GestureDetector( return Column(
onLongPressStart: (details) { mainAxisSize: MainAxisSize.min,
MediaContextMenu.show( children: [
context: context, ListTile(
position: details.globalPosition,
mediaType: ContextMenuMediaType.track,
item: track,
isFavorite: track.favorite ?? false,
isInLibrary: track.inLibrary,
onToggleFavorite: () => _toggleTrackFavorite(index),
onToggleLibrary: () => _toggleTrackLibrary(index),
adaptiveColorScheme: adaptiveScheme,
);
},
child: ListTile(
contentPadding: const EdgeInsets.only(left: 12, right: 20),
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Track number // Track number
SizedBox( SizedBox(
width: 24, width: 28,
child: Text( child: Text(
'${index + 1}', '${index + 1}',
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
@@ -1077,7 +909,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
textAlign: TextAlign.right, textAlign: TextAlign.right,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 12),
// Track artwork // Track artwork
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -1126,31 +958,98 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
trailing: Padding( trailing: track.duration != null
padding: const EdgeInsets.only(right: 4), ? Text(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!), _formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5), color: colorScheme.onSurface.withOpacity(0.5),
), ),
textAlign: TextAlign.right, )
: null,
onTap: () {
if (isExpanded) {
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
),
// Expandable action buttons
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Radio button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.radio, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
const SizedBox(width: 10),
// Favorite button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _toggleTrackFavorite(index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
track.favorite == true ? Icons.favorite : Icons.favorite_border,
size: 20,
color: track.favorite == true
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
), ),
), ),
], ],
), ),
)
: const SizedBox.shrink(),
), ),
onTap: () => _playTrack(index), ],
),
); );
}, },
childCount: _tracks.length, childCount: _tracks.length,
@@ -1158,28 +1057,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
), ),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
], ],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
); );
}, },
), ),
+125 -347
View File
@@ -1,23 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart'; import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart'; import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/settings_service.dart';
import '../constants/hero_tags.dart'; import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/page_transitions.dart';
import 'podcast_episode_detail_screen.dart';
class PodcastDetailScreen extends StatefulWidget { class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast; final MediaItem podcast;
@@ -43,8 +36,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
bool _isLoadingEpisodes = false; bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false; bool _isDescriptionExpanded = false;
bool _isInLibrary = false; bool _isInLibrary = false;
bool _isFavorite = false; String? _expandedEpisodeId; // Track which episode is expanded for actions
String _sortOrder = 'newest'; // 'newest', 'oldest', 'alpha', 'duration'
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : ''; String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -52,115 +44,14 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_isInLibrary = _checkIfInLibrary(widget.podcast); _isInLibrary = _checkIfInLibrary(widget.podcast);
_isFavorite = widget.podcast.favorite ?? false;
_loadSortPreference();
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Defer episode loading until after hero animation completes
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () { Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) { if (mounted) {
_extractColors();
}
});
_loadEpisodes(); _loadEpisodes();
}
}); });
});
}
Future<void> _loadSortPreference() async {
final sortOrder = await SettingsService.getPodcastEpisodesSortOrder();
if (mounted) {
setState(() {
_sortOrder = sortOrder;
});
}
}
void _toggleSortOrder() {
// Cycle through sort orders: newest -> oldest -> alpha -> duration -> newest
String newOrder;
switch (_sortOrder) {
case 'newest':
newOrder = 'oldest';
break;
case 'oldest':
newOrder = 'alpha';
break;
case 'alpha':
newOrder = 'duration';
break;
default:
newOrder = 'newest';
}
setState(() {
_sortOrder = newOrder;
_sortEpisodes();
});
SettingsService.setPodcastEpisodesSortOrder(newOrder);
}
void _sortEpisodes() {
switch (_sortOrder) {
case 'newest':
_episodes.sort((a, b) {
final aDate = _getPublishDate(a.metadata);
final bDate = _getPublishDate(b.metadata);
if (aDate == null && bDate == null) return 0;
if (aDate == null) return 1;
if (bDate == null) return -1;
return bDate.compareTo(aDate); // Newest first
});
break;
case 'oldest':
_episodes.sort((a, b) {
final aDate = _getPublishDate(a.metadata);
final bDate = _getPublishDate(b.metadata);
if (aDate == null && bDate == null) return 0;
if (aDate == null) return 1;
if (bDate == null) return -1;
return aDate.compareTo(bDate); // Oldest first
});
break;
case 'alpha':
_episodes.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
break;
case 'duration':
_episodes.sort((a, b) {
final aDur = a.duration ?? Duration.zero;
final bDur = b.duration ?? Duration.zero;
return aDur.compareTo(bDur); // Shortest first
});
break;
}
}
DateTime? _getPublishDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
if (dateValue is String) {
return DateTime.tryParse(dateValue);
} else if (dateValue is int) {
return DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
} catch (e) {
// Silent failure
}
return null;
} }
/// Check if podcast is in library /// Check if podcast is in library
@@ -295,71 +186,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
} }
} }
/// Toggle favorite status
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !_isFavorite;
try {
bool success;
if (newState) {
String actualProvider = widget.podcast.provider;
String actualItemId = widget.podcast.itemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
final mapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.podcast.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.podcast.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'podcast',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.podcast.provider == 'library') {
libraryItemId = int.tryParse(widget.podcast.itemId);
} else if (widget.podcast.providerMappings != null) {
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.podcast.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
success = await maProvider.removeFromFavorites(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
setState(() => _isFavorite = newState);
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error toggling podcast favorite: $e');
}
}
Future<void> _extractColors() async { Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation // Use initialImageUrl first for consistency with hero animation
@@ -367,17 +193,14 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (imageUrl != null) { if (imageUrl != null) {
try { try {
// Use isolate-based extraction to avoid blocking the main thread final colorSchemes = await PaletteHelper.extractColorSchemes(
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl); CachedNetworkImageProvider(imageUrl),
);
if (mounted && colorSchemes != null) { if (mounted && colorSchemes != null) {
setState(() { setState(() {
_lightColorScheme = colorSchemes.$1; _lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2; _darkColorScheme = colorSchemes.$2;
}); });
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
} }
} catch (e) { } catch (e) {
_logger.log('🎙️ Error extracting colors: $e'); _logger.log('🎙️ Error extracting colors: $e');
@@ -388,36 +211,24 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
Future<void> _loadEpisodes() async { Future<void> _loadEpisodes() async {
if (_isLoadingEpisodes) return; if (_isLoadingEpisodes) return;
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(() { setState(() {
_isLoadingEpisodes = true; _isLoadingEpisodes = true;
}); });
}
// 2. Fetch fresh data in background
try { 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 loading with the podcast's own ID and provider first
episodes = await maProvider.getPodcastEpisodesWithCache( try {
episodes = await maProvider.api!.getPodcastEpisodes(
widget.podcast.itemId, widget.podcast.itemId,
provider: widget.podcast.provider, provider: widget.podcast.provider,
forceRefresh: cached != null && cached.isNotEmpty,
); );
} catch (e) {
_logger.log('🎙️ Primary episode load failed: $e');
}
// If that failed and we have provider mappings, try those // If that failed and we have provider mappings, try those
if (episodes.isEmpty && widget.podcast.providerMappings != null) { if (episodes.isEmpty && widget.podcast.providerMappings != null) {
@@ -425,7 +236,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) { if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
try { try {
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}'); _logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
episodes = await maProvider.getPodcastEpisodesWithCache( episodes = await maProvider.api!.getPodcastEpisodes(
mapping.itemId, mapping.itemId,
provider: mapping.providerInstance, provider: mapping.providerInstance,
); );
@@ -440,26 +251,29 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
} }
} }
if (mounted && episodes.isNotEmpty) { if (mounted) {
final changed = _episodes.length != episodes.length;
if (changed || _episodes.isEmpty) {
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}'); _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(() { setState(() {
_episodes = episodes; _episodes = episodes;
_sortEpisodes();
}); });
} }
} }
} catch (e) { } catch (e) {
_logger.log('🎙️ Error loading episodes: $e'); _logger.log('🎙️ Error loading episodes: $e');
} } finally {
if (mounted) {
if (mounted && _isLoadingEpisodes) {
setState(() { setState(() {
_isLoadingEpisodes = false; _isLoadingEpisodes = false;
}); });
} }
} }
}
void _playEpisode(MediaItem episode) async { void _playEpisode(MediaItem episode) async {
final maProvider = context.read<MusicAssistantProvider>(); final maProvider = context.read<MusicAssistantProvider>();
@@ -592,35 +406,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
} }
} }
/// Show context menu for an episode
void _showEpisodeContextMenu(BuildContext context, Offset position, MediaItem episode, String? episodeImageUrl) {
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.podcastEpisode,
item: episode,
isFavorite: false,
isInLibrary: false,
onPlay: () => _playEpisode(episode),
onPlayOn: () => _showPlayOnMenu(context, episode),
onAddToQueue: () => _addToQueue(episode),
onViewDetails: () {
final maProvider = this.context.read<MusicAssistantProvider>();
final initialImageUrl = episodeImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
Navigator.push(
this.context,
FadeSlidePageRoute(
child: PodcastEpisodeDetailScreen(
episode: episode,
podcast: widget.podcast,
initialImageUrl: initialImageUrl,
),
),
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation // CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
@@ -670,9 +455,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0); final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70; final expandedHeight = coverSize + 70;
return Stack( return CustomScrollView(
children: [
CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: expandedHeight, expandedHeight: expandedHeight,
@@ -754,7 +537,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@@ -786,6 +569,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
children: [ children: [
// Main Play Latest Episode Button // Main Play Latest Episode Button
Expanded( Expanded(
flex: 2,
child: SizedBox( child: SizedBox(
height: 50, height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -807,6 +591,24 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// "Play on..." Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoadingEpisodes || _episodes.isEmpty
? null
: () => _showPlayOnMenu(context, _episodes.first),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
),
),
const SizedBox(width: 12),
// Library button // Library button
SizedBox( SizedBox(
height: 50, height: 50,
@@ -820,76 +622,10 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
), ),
child: Icon( child: Icon(
Symbols.book_2, _isInLibrary ? Icons.library_add_check : Icons.library_add,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary color: _isInLibrary
? colorScheme.primary ? colorScheme.primary
: Colors.white70, : colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Favorite button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleFavorite,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
if (_isLoadingEpisodes || _episodes.isEmpty) return;
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.podcast,
item: widget.podcast,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: _isLoadingEpisodes || _episodes.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
), ),
), ),
), ),
@@ -996,28 +732,29 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final episode = _episodes[index]; final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?; final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration; final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256); final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata // Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata); final releaseDate = _formatReleaseDate(episode.metadata);
return GestureDetector( return Column(
children: [
InkWell(
onTap: () => _playEpisode(episode), onTap: () => _playEpisode(episode),
onLongPressStart: (details) => _showEpisodeContextMenu( onLongPress: () {
context, setState(() {
details.globalPosition, _expandedEpisodeId = isExpanded ? null : episodeId;
episode, });
episodeImageUrl, },
),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Episode cover // Episode cover (bigger than before)
Hero( ClipRRect(
tag: HeroTags.podcastEpisodeCover + (episode.uri ?? episode.itemId),
child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
width: 72, width: 72,
@@ -1047,7 +784,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
), ),
), ),
), ),
),
const SizedBox(width: 12), const SizedBox(width: 12),
// Episode info // Episode info
Expanded( Expanded(
@@ -1066,7 +802,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
episodeDescription, episodeDescription,
maxLines: 2, maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6), color: colorScheme.onSurface.withOpacity(0.6),
@@ -1116,6 +852,70 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
], ],
), ),
), ),
),
// Expanded action buttons (matching album track style)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
); );
}, },
childCount: _episodes.length, childCount: _episodes.length,
@@ -1127,28 +927,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
child: SizedBox(height: BottomSpacing.withMiniPlayer), child: SizedBox(height: BottomSpacing.withMiniPlayer),
), ),
], ],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
); );
}, },
), ),
@@ -1,528 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class PodcastEpisodeDetailScreen extends StatefulWidget {
final MediaItem episode;
final MediaItem podcast;
final String? heroTagSuffix;
final String? initialImageUrl;
const PodcastEpisodeDetailScreen({
super.key,
required this.episode,
required this.podcast,
this.heroTagSuffix,
this.initialImageUrl,
});
@override
State<PodcastEpisodeDetailScreen> createState() => _PodcastEpisodeDetailScreenState();
}
class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen> {
final _logger = DebugLogger();
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors();
}
});
}
Future<void> _extractColors() async {
final maProvider = context.read<MusicAssistantProvider>();
// Try episode image first, fallback to podcast image
final imageUrl = widget.initialImageUrl ??
maProvider.getImageUrl(widget.episode, size: 512) ??
maProvider.getPodcastImageUrl(widget.podcast);
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Error extracting colors for episode: $e');
}
}
}
void _playEpisode() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer != null) {
try {
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, widget.episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing: ${widget.episode.name}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error playing episode: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to play episode: $e')),
);
}
}
} else {
_showPlayOnMenu();
}
}
void _showPlayOnMenu() {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.selectPlayerForEpisode,
onPlayerSelected: (player) async {
maProvider.selectPlayer(player);
maProvider.setCurrentPodcastName(widget.podcast.name);
await maProvider.api?.playPodcastEpisode(player.playerId, widget.episode);
},
);
}
Future<void> _addToQueue() async {
final maProvider = context.read<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
try {
final track = Track(
itemId: widget.episode.itemId,
provider: widget.episode.provider,
name: widget.episode.name,
uri: widget.episode.uri,
duration: widget.episode.duration,
metadata: widget.episode.metadata,
);
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding episode to queue: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
);
}
}
}
String _formatDuration(Duration? duration) {
if (duration == null || duration == Duration.zero) return '';
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '$minutes min';
}
String? _formatPublishDate(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
dynamic dateValue = metadata['published'] ??
metadata['pub_date'] ??
metadata['release_date'] ??
metadata['aired'] ??
metadata['timestamp'];
if (dateValue == null) return null;
try {
DateTime? date;
if (dateValue is String) {
date = DateTime.tryParse(dateValue);
} else if (dateValue is int) {
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
}
if (date != null) {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
} catch (e) {
_logger.log('Error parsing release date: $e');
}
return null;
}
String _stripHtml(String htmlText) {
final withoutTags = htmlText.replaceAll(RegExp(r'<[^>]*>'), '');
final decoded = withoutTags
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
return decoded.replaceAll(RegExp(r'\s+'), ' ').trim();
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final episodeImageUrl = maProvider.getImageUrl(widget.episode, size: 512);
final podcastImageUrl = maProvider.getPodcastImageUrl(widget.podcast);
final imageUrl = widget.initialImageUrl ?? episodeImageUrl ?? podcastImageUrl;
final adaptiveTheme = context.select<ThemeProvider, bool>(
(provider) => provider.adaptiveTheme,
);
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveLightScheme,
);
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
(provider) => provider.adaptiveDarkScheme,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
ColorScheme? adaptiveScheme;
if (adaptiveTheme) {
adaptiveScheme = isDark
? (_darkColorScheme ?? adaptiveDarkScheme)
: (_lightColorScheme ?? adaptiveLightScheme);
}
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final description = widget.episode.metadata?['description'] as String?;
final duration = widget.episode.duration;
final publishDate = _formatPublishDate(widget.episode.metadata);
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
clearAdaptiveColorsOnBack(context);
}
},
child: Scaffold(
backgroundColor: colorScheme.surface,
body: LayoutBuilder(
builder: (context, constraints) {
final coverSize = (constraints.maxWidth * 0.6).clamp(180.0, 280.0);
final expandedHeight = coverSize + 70;
return Stack(
children: [
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
backgroundColor: colorScheme.surface,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
clearAdaptiveColorsOnBack(context);
Navigator.pop(context);
},
color: colorScheme.onSurface,
),
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 60),
Hero(
tag: HeroTags.podcastEpisodeCover +
(widget.episode.uri ?? widget.episode.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: coverSize,
height: coverSize,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
if (widget.episode.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.episode.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
],
),
),
),
// Header content with title, podcast name, metadata, and actions
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode title
Text(
widget.episode.name,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap8,
// Podcast name (tappable to go back)
InkWell(
onTap: () => Navigator.pop(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
widget.podcast.name,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
),
),
Spacing.vGap16,
// Duration and publish date
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 16,
color: colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
if (duration != null && duration > Duration.zero && publishDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (publishDate != null)
Text(
publishDate,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
const SizedBox(height: 24),
// Action buttons row
Row(
children: [
// Main Play Button
Expanded(
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: _playEpisode,
icon: const Icon(Icons.play_arrow_rounded),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
const SizedBox(width: 12),
// Play On... Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _showPlayOnMenu,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 25),
),
),
const SizedBox(width: 12),
// Add to Queue Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _addToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 25),
),
),
],
),
],
),
),
),
// Full scrollable description (key feature)
if (description != null && description.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 0, 24.0, 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.about,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Spacing.vGap12,
// Full description - no maxLines, fully scrollable with selectable text
SelectableText(
_stripHtml(description),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.8),
height: 1.5,
),
),
],
),
),
),
// Bottom spacing for mini player
SliverToBoxAdapter(
child: SizedBox(height: BottomSpacing.withMiniPlayer),
),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: BottomSpacing.withMiniPlayer,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withOpacity(0.0),
colorScheme.surface,
],
),
),
),
),
),
],
);
},
),
),
);
}
}
+257
View File
@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../l10n/app_localizations.dart';
/// A screen for scanning QR codes to get the Remote Access ID.
class QrScannerScreen extends StatefulWidget {
const QrScannerScreen({super.key});
@override
State<QrScannerScreen> createState() => _QrScannerScreenState();
}
class _QrScannerScreenState extends State<QrScannerScreen> {
late final MobileScannerController _controller;
bool _hasScanned = false;
String _statusMessage = '';
@override
void initState() {
super.initState();
_controller = MobileScannerController(
detectionSpeed: DetectionSpeed.normal,
facing: CameraFacing.back,
formats: [BarcodeFormat.qrCode], // Only scan QR codes
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_hasScanned) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
final String? code = barcode.rawValue;
if (code != null && code.isNotEmpty) {
final remoteId = _extractRemoteId(code);
if (remoteId != null && remoteId.isNotEmpty) {
_hasScanned = true;
Navigator.of(context).pop(remoteId);
return;
} else {
// Show what was scanned for debugging
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Scanned: $code'),
duration: const Duration(seconds: 5),
),
);
}
}
}
}
/// Extract the remote ID from various QR code formats
String? _extractRemoteId(String code) {
// Format 1: Raw remote ID (alphanumeric)
if (RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(code) && code.length >= 8) {
return code;
}
// Format 2: ma://remote/<id>
if (code.startsWith('ma://remote/')) {
return code.replaceFirst('ma://remote/', '');
}
// Format 3: URL from app.music-assistant.io
// Examples:
// - https://app.music-assistant.io/?cloudInstanceId=<id>
// - https://app.music-assistant.io/#/home?cloudInstanceId=<id>
if (code.contains('music-assistant.io')) {
final uri = Uri.tryParse(code);
if (uri != null) {
// Check query parameters in main URL
final queryParams = ['cloudInstanceId', 'cloud_instance_id', 'remoteid', 'remote_id', 'id'];
for (final param in queryParams) {
final value = uri.queryParameters[param];
if (value != null && value.isNotEmpty) {
return value;
}
}
// Check fragment (hash) for query params: #/home?cloudInstanceId=xxx
final fragment = uri.fragment;
if (fragment.contains('?')) {
final fragmentQuery = fragment.split('?').last;
final fragmentParams = Uri.splitQueryString(fragmentQuery);
for (final param in queryParams) {
final value = fragmentParams[param];
if (value != null && value.isNotEmpty) {
return value;
}
}
}
// Check if ID is in path segments
for (final segment in uri.pathSegments) {
if (segment.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(segment)) {
return segment;
}
}
}
}
// Format 4: Other URL formats - try to extract from query params or path
if (code.contains('://')) {
final uri = Uri.tryParse(code);
if (uri != null) {
// Check common query param names
for (final param in ['id', 'remoteid', 'remote_id', 'cloudInstanceId']) {
final value = uri.queryParameters[param];
if (value != null && value.isNotEmpty) {
return value;
}
}
// Last path segment if it looks like an ID
if (uri.pathSegments.isNotEmpty) {
final last = uri.pathSegments.last;
if (last.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(last)) {
return last;
}
}
}
}
return null;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(S.of(context)!.scanQrCode),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
actions: [
// Toggle torch
IconButton(
icon: ValueListenableBuilder(
valueListenable: _controller,
builder: (context, state, child) {
return Icon(
state.torchState == TorchState.on
? Icons.flash_on
: Icons.flash_off,
);
},
),
onPressed: () => _controller.toggleTorch(),
),
],
),
body: Stack(
children: [
// Camera preview
MobileScanner(
controller: _controller,
onDetect: _onDetect,
errorBuilder: (context, error, child) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt_outlined,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Camera error: ${error.errorCode.name}',
style: TextStyle(color: colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
error.errorDetails?.message ?? S.of(context)!.cameraPermissionDenied,
style: TextStyle(color: colorScheme.error.withOpacity(0.7)),
textAlign: TextAlign.center,
),
],
),
);
},
),
// Scan overlay with instruction
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Scan frame
Container(
width: 280,
height: 280,
decoration: BoxDecoration(
border: Border.all(
color: Colors.white.withOpacity(0.8),
width: 2,
),
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 32),
// Instruction text
Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
S.of(context)!.pointCameraAtQr,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
if (_statusMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_statusMessage,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 12,
),
),
],
],
),
),
],
),
),
],
),
);
}
}
+5 -6
View File
@@ -132,8 +132,8 @@ class _QueueScreenState extends State<QueueScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Use select to only rebuild when selectedPlayer changes final maProvider = context.watch<MusicAssistantProvider>();
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer); final player = maProvider.selectedPlayer;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF1a1a1a), backgroundColor: const Color(0xFF1a1a1a),
@@ -153,7 +153,7 @@ class _QueueScreenState extends State<QueueScreen> {
IconButton( IconButton(
icon: const Icon(Icons.swap_horiz_rounded), icon: const Icon(Icons.swap_horiz_rounded),
tooltip: S.of(context)!.transferQueue, tooltip: S.of(context)!.transferQueue,
onPressed: () => _handleTransferQueue(context.read<MusicAssistantProvider>()), onPressed: () => _handleTransferQueue(maProvider),
), ),
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
@@ -161,12 +161,11 @@ class _QueueScreenState extends State<QueueScreen> {
), ),
], ],
), ),
body: _buildBody(context), body: _buildBody(maProvider),
); );
} }
Widget _buildBody(BuildContext context) { Widget _buildBody(MusicAssistantProvider maProvider) {
final maProvider = context.read<MusicAssistantProvider>();
if (_isLoading) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
File diff suppressed because it is too large Load Diff
+77 -576
View File
@@ -1,18 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../theme/theme_provider.dart'; import '../theme/theme_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../utils/page_transitions.dart';
import '../providers/navigation_provider.dart';
import '../widgets/global_player_overlay.dart'; import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart'; import 'debug_log_screen.dart';
import 'login_screen.dart'; import 'login_screen.dart';
@@ -25,10 +19,8 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
final _scrollController = ScrollController();
final _lastFmApiKeyController = TextEditingController(); final _lastFmApiKeyController = TextEditingController();
final _audioDbApiKeyController = TextEditingController(); final _audioDbApiKeyController = TextEditingController();
String _appVersion = '';
// Main rows (default on) // Main rows (default on)
bool _showRecentAlbums = true; bool _showRecentAlbums = true;
bool _showDiscoverArtists = true; bool _showDiscoverArtists = true;
@@ -47,77 +39,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Home row order // Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder); List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Player settings // Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false; bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true; bool _volumePrecisionMode = true;
// Hint settings // Hint settings
bool _showHints = true; bool _showHints = true;
// Display settings // Display settings
bool _showProviderIcons = true; bool _showProviderIcons = true;
// Discovery folders state
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {};
bool _isLoadingDiscoveryFolders = false;
/// All available static home screen rows (that can be toggled on/off)
static const List<String> _allAvailableRows = [
'recent-albums',
'discover-artists',
'discover-albums',
'discovery-mixes',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
/// Filtered row order for the draggable list
/// Shows all available rows (in home order + discovery rows), regardless of enabled state
List<String> get _filteredRowOrder {
final rows = <String>[];
// First, add all static rows in their current home order
for (final rowId in _homeRowOrder) {
if (rowId == 'discovery-mixes') continue;
rows.add(rowId);
}
// Then, add any static rows that aren't in home order yet (so users can enable them)
for (final rowId in _allAvailableRows) {
if (!_homeRowOrder.contains(rowId) && rowId != 'discovery-mixes') {
rows.add(rowId);
}
}
// Finally, add any discovery rows from provider that aren't in home order yet
for (final folder in _discoveryFolders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
rows.add(discoveryRowId);
}
}
return rows;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadSettings(); _loadSettings();
_loadAppVersion();
}
Future<void> _loadAppVersion() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = '${info.version}+${info.buildNumber}';
});
}
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
@@ -151,7 +85,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final rowOrder = await SettingsService.getHomeRowOrder(); final rowOrder = await SettingsService.getHomeRowOrder();
// Load player settings // Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers(); final smartSort = await SettingsService.getSmartSortPlayers();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final volumePrecision = await SettingsService.getVolumePrecisionMode(); final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings // Load hint settings
@@ -160,9 +96,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load display settings // Load display settings
final showProviderIcons = await SettingsService.getShowProviderIcons(); final showProviderIcons = await SettingsService.getShowProviderIcons();
// Load discovery row preferences
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) { if (mounted) {
setState(() { setState(() {
_showRecentAlbums = showRecent; _showRecentAlbums = showRecent;
@@ -178,104 +111,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAudiobooks = showDiscAudiobooks; _showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries; _showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder; _homeRowOrder = rowOrder;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort; _smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision; _volumePrecisionMode = volumePrecision;
_showHints = showHints; _showHints = showHints;
_showProviderIcons = showProviderIcons; _showProviderIcons = showProviderIcons;
_discoveryRowEnabled = discoveryRowPrefs;
}); });
} }
// Load discovery folders after initial state is set
_loadDiscoveryFolders();
} }
@override @override
void dispose() { void dispose() {
_scrollController.dispose();
_lastFmApiKeyController.dispose(); _lastFmApiKeyController.dispose();
_audioDbApiKeyController.dispose(); _audioDbApiKeyController.dispose();
super.dispose(); super.dispose();
} }
/// Load discovery folders from Music Assistant
Future<void> _loadDiscoveryFolders() async {
if (_discoveryFolders.isEmpty && mounted) {
setState(() {
_isLoadingDiscoveryFolders = true;
});
}
final provider = context.read<MusicAssistantProvider>();
try {
final folders = await provider.getDiscoveryFoldersWithCache();
// Also reload discovery row preferences to ensure they're up to date
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Refresh discovery folders from Music Assistant
Future<void> _refreshDiscoveryFolders() async {
final provider = context.read<MusicAssistantProvider>();
try {
setState(() {
_isLoadingDiscoveryFolders = true;
});
await provider.refreshDiscoveryFolders();
final folders = provider.getCachedDiscoveryFolders() ?? [];
if (mounted) {
setState(() {
_discoveryFolders = folders;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Toggle a specific discovery row on/off
Future<void> _toggleDiscoveryRow(String itemId, bool enabled) async {
setState(() {
_discoveryRowEnabled[itemId] = enabled;
});
await SettingsService.setDiscoveryRowPreference(itemId, enabled);
}
// Helper to get row display info // Helper to get row display info
Map<String, String> _getRowInfo(String rowId) { Map<String, String> _getRowInfo(String rowId) {
final s = S.of(context)!; final s = S.of(context)!;
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == itemId,
orElse: () => RecommendationFolder(
itemId: itemId,
provider: 'unknown',
name: rowId,
items: [],
),
);
return {'title': folder.name, 'subtitle': 'Discovery row from Music Assistant'};
}
switch (rowId) { switch (rowId) {
case 'recent-albums': case 'recent-albums':
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums}; return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
@@ -283,8 +138,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists}; return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums': case 'discover-albums':
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums}; return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'discovery-mixes':
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription};
case 'continue-listening': case 'continue-listening':
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress}; return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks': case 'discover-audiobooks':
@@ -310,11 +163,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to get row enabled state // Helper to get row enabled state
bool _getRowEnabled(String rowId) { bool _getRowEnabled(String rowId) {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) { switch (rowId) {
case 'recent-albums': case 'recent-albums':
return _showRecentAlbums; return _showRecentAlbums;
@@ -348,20 +196,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to set row enabled state // Helper to set row enabled state
void _setRowEnabled(String rowId, bool value) { void _setRowEnabled(String rowId, bool value) {
setState(() { setState(() {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
_discoveryRowEnabled[itemId] = value;
SettingsService.setDiscoveryRowPreference(itemId, value);
// Always add to home row order for position tracking
if (!_homeRowOrder.contains(rowId)) {
_homeRowOrder.add(rowId);
SettingsService.setHomeRowOrder(_homeRowOrder);
}
return;
}
// Save the setting
switch (rowId) { switch (rowId) {
case 'recent-albums': case 'recent-albums':
_showRecentAlbums = value; _showRecentAlbums = value;
@@ -411,14 +245,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoritePodcasts = value; _showFavoritePodcasts = value;
SettingsService.setShowFavoritePodcasts(value); SettingsService.setShowFavoritePodcasts(value);
break; break;
default:
break;
}
// When enabling a row, add it to home row order if not already present
if (value && !_homeRowOrder.contains(rowId)) {
_homeRowOrder.add(rowId);
SettingsService.setHomeRowOrder(_homeRowOrder);
} }
}); });
} }
@@ -441,8 +267,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Use select to only rebuild when connectionState changes final provider = context.watch<MusicAssistantProvider>();
final connectionState = context.select<MusicAssistantProvider, MAConnectionState>((p) => p.connectionState);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -453,13 +278,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded), icon: const Icon(Icons.arrow_back_rounded),
onPressed: () { onPressed: () => Navigator.pop(context),
// Settings is rendered conditionally in HomeScreen (not pushed as a route),
// so Navigator.pop would pop the entire HomeScreen causing a black screen.
// Instead, switch tab back to Home and restore the mini player.
GlobalPlayerOverlay.showPlayer();
navigationProvider.setSelectedIndex(0);
},
color: colorScheme.onBackground, color: colorScheme.onBackground,
), ),
title: Text( title: Text(
@@ -476,7 +295,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(
context, context,
FadeSlidePageRoute(child: const DebugLogScreen()), MaterialPageRoute(
builder: (context) => const DebugLogScreen(),
),
); );
}, },
color: colorScheme.onBackground, color: colorScheme.onBackground,
@@ -485,7 +306,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -525,15 +345,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
_getStatusIcon(connectionState), _getStatusIcon(provider.connectionState),
color: _getStatusColor(connectionState, colorScheme), color: _getStatusColor(provider.connectionState, colorScheme),
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
_getStatusText(connectionState), _getStatusText(provider.connectionState),
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: _getStatusColor(connectionState, colorScheme), color: _getStatusColor(provider.connectionState, colorScheme),
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -883,6 +703,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.preferLocalPlayer,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.preferLocalPlayerDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _preferLocalPlayer,
onChanged: (value) {
setState(() => _preferLocalPlayer = value);
SettingsService.setPreferLocalPlayer(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -907,7 +754,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -916,11 +763,36 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
child: SwitchListTile( child: SwitchListTile(
title: Text( title: Text(
S.of(context)!.volumePrecisionMode, S.of(context)!.disableAutoSwitch,
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
), ),
subtitle: Text( subtitle: Text(
S.of(context)!.volumePrecisionModeDescription, S.of(context)!.disableAutoSwitchDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _disableAutoSwitch,
onChanged: (value) {
setState(() => _disableAutoSwitch = value);
SettingsService.setDisableAutoSwitch(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
'Volume precision mode',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Hold still while adjusting volume for fine control',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
), ),
value: _volumePrecisionMode, value: _volumePrecisionMode,
@@ -1031,7 +903,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Reorderable home rows list (exclude discovery-mixes from list) // Reorderable home rows list
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3), color: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -1042,29 +914,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
itemCount: _filteredRowOrder.length, itemCount: _homeRowOrder.length,
onReorder: (oldIndex, newIndex) { onReorder: (oldIndex, newIndex) {
// Create a mutable copy of the filtered list for reordering
final displayRows = List<String>.from(_filteredRowOrder);
if (newIndex > oldIndex) newIndex--;
final item = displayRows.removeAt(oldIndex);
displayRows.insert(newIndex, item);
setState(() { setState(() {
// Update _homeRowOrder with the new order (all rows, including discovery) if (newIndex > oldIndex) newIndex--;
_homeRowOrder = displayRows; final item = _homeRowOrder.removeAt(oldIndex);
_homeRowOrder.insert(newIndex, item);
}); });
SettingsService.setHomeRowOrder(_homeRowOrder); SettingsService.setHomeRowOrder(_homeRowOrder);
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
final rowId = _filteredRowOrder[index]; final rowId = _homeRowOrder[index];
final rowInfo = _getRowInfo(rowId); final rowInfo = _getRowInfo(rowId);
final isEnabled = _getRowEnabled(rowId); final isEnabled = _getRowEnabled(rowId);
return Container( return Container(
key: ValueKey(rowId), key: ValueKey(rowId),
decoration: BoxDecoration( decoration: BoxDecoration(
border: index < _filteredRowOrder.length - 1 border: index < _homeRowOrder.length - 1
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2))) ? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
: null, : null,
), ),
@@ -1195,149 +1062,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// About & Support section
Text(
S.of(context)!.aboutAndSupport,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Support Development banner
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFFEA4AAA).withOpacity(0.15),
Color(0xFF1F6FEB).withOpacity(0.15),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.favorite_rounded,
color: Color(0xFFEA4AAA),
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
S.of(context)!.supportDevelopment,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(
S.of(context)!.ensembleIsUnofficial,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(height: 16),
Row(
children: [
// GitHub Sponsors button
Expanded(
child: _DonationButton(
label: S.of(context)!.sponsorOnGitHub,
color: Color(0xFF1F6FEB),
iconWidget: Icon(MdiIcons.github, size: 20),
onTap: () => _launchUrl('https://github.com/sponsors/CollotsSpot'),
),
),
const SizedBox(width: 12),
// Ko-fi button
Expanded(
child: _DonationButton(
icon: null,
label: S.of(context)!.buyMeAKoFi,
color: Color(0xFFFF5E5B),
iconWidget: Icon(Icons.coffee_rounded, color: Colors.white),
onTap: () => _launchUrl('https://ko-fi.com/collotsspot'),
),
),
],
),
],
),
),
const SizedBox(height: 16),
// About Ensemble card
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.aboutEnsemble,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
S.of(context)!.version,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(width: 4),
Text(
_appVersion,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
_LinkButton(
icon: Icons.code_rounded,
label: S.of(context)!.visitGitHubRepo,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble'),
),
const SizedBox(height: 8),
_LinkButton(
icon: Icons.bug_report_outlined,
label: S.of(context)!.reportABug,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble/issues'),
),
],
),
),
const SizedBox(height: 32),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 50, height: 50,
@@ -1415,227 +1139,4 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
/// Build the discovery section with individual row toggles
Widget _buildDiscoverySection(ColorScheme colorScheme, TextTheme textTheme) {
final s = S.of(context)!;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and refresh button
Row(
children: [
Icon(
Icons.explore_outlined,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
s.discoveryRows,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
if (_isLoadingDiscoveryFolders)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary.withOpacity(0.7),
),
),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _refreshDiscoveryFolders,
tooltip: s.refreshDiscoveryRows,
color: colorScheme.onSurfaceVariant,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
),
const SizedBox(height: 12),
// Discovery rows list
if (_discoveryFolders.isEmpty && !_isLoadingDiscoveryFolders)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
s.noDiscoveryRows,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
)
else
..._discoveryFolders.map((folder) {
final itemId = folder.itemId;
final isEnabled = _discoveryRowEnabled[itemId] ?? false;
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: SwitchListTile(
title: Text(
folder.name,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
),
),
subtitle: folder.provider.isNotEmpty
? Text(
folder.provider,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 11,
),
)
: null,
value: isEnabled,
onChanged: (value) => _toggleDiscoveryRow(itemId, value),
activeColor: colorScheme.primary,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
dense: true,
),
);
}).toList(),
],
),
);
}
}
// Custom donation button widget
class _DonationButton extends StatelessWidget {
final String? icon;
final String label;
final Color color;
final VoidCallback onTap;
final Widget? iconWidget;
const _DonationButton({
this.icon,
required this.label,
required this.color,
required this.onTap,
this.iconWidget,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (iconWidget != null)
iconWidget!
else if (icon != null)
Image.asset(
icon!,
width: 20,
height: 20,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
);
}
}
// Custom link button widget
class _LinkButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _LinkButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Icon(
icon,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.primary,
),
),
const SizedBox(width: 4),
Icon(
Icons.open_in_new,
size: 14,
color: colorScheme.primary.withOpacity(0.7),
),
],
),
),
),
);
}
} }
+1 -770
View File
@@ -2,17 +2,12 @@ import 'dart:async';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart'; import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:rxdart/rxdart.dart';
import '../debug_logger.dart'; import '../debug_logger.dart';
import '../auth/auth_manager.dart'; import '../auth/auth_manager.dart';
import '../settings_service.dart';
import '../sync_service.dart';
import '../../providers/music_assistant_provider.dart';
import '../../models/media_item.dart' as ma;
/// Custom AudioHandler for Ensemble that provides full control over /// Custom AudioHandler for Ensemble that provides full control over
/// notification actions and metadata updates. /// notification actions and metadata updates.
class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
final AuthManager authManager; final AuthManager authManager;
final _logger = DebugLogger(); final _logger = DebugLogger();
@@ -37,16 +32,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
// Track whether we're in remote control mode (controlling MA player, not playing locally) // Track whether we're in remote control mode (controlling MA player, not playing locally)
bool _isRemoteMode = false; bool _isRemoteMode = false;
// Android Auto: provider reference (set after app initialises)
MusicAssistantProvider? _autoProvider;
// Android Auto: track queue cache — maps context key to ordered track list.
// Populated during getChildren so playFromMediaId can queue the full album/playlist.
final Map<String, List<ma.Track>> _autoTrackCache = {};
// Android Auto: subjects for subscribeToChildren
final Map<String, BehaviorSubject<Map<String, dynamic>>> _autoChildrenSubjects = {};
// Custom control for switching players (uses stop action with custom icon) // Custom control for switching players (uses stop action with custom icon)
static const _switchPlayerControl = MediaControl( static const _switchPlayerControl = MediaControl(
androidIcon: 'drawable/ic_switch_player', androidIcon: 'drawable/ic_switch_player',
@@ -327,760 +312,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
MediaItem? get currentMediaItem => _currentMediaItem; MediaItem? get currentMediaItem => _currentMediaItem;
// ---------------------------------------------------------------------------
// Android Auto — media browsing and playback
// ---------------------------------------------------------------------------
/// Wire the provider in after the app has initialised.
/// Called from _MusicAssistantAppState.initState().
void setProvider(MusicAssistantProvider provider) {
_autoProvider = provider;
_logger.log('AndroidAuto: provider set');
}
// Media ID constants — root categories
static const _autoIdHome = 'cat|home';
static const _autoIdMusic = 'cat|music';
static const _autoIdAudiobooks = 'cat|audiobooks';
static const _autoIdPodcasts = 'cat|podcasts';
static const _autoIdRadio = 'cat|radio';
// Media ID constants — Music subcategories
static const _autoIdPlaylists = 'cat|playlists';
static const _autoIdArtists = 'cat|artists';
static const _autoIdAlbums = 'cat|albums';
static const _autoIdFavorites = 'cat|favorites';
// Media ID constants — Audiobook subcategories
static const _autoIdAbAuthors = 'cat|ab_authors';
static const _autoIdAbBooks = 'cat|ab_books';
static const _autoIdAbSeries = 'cat|ab_series';
// Android Auto content style hints
static const _gridHints = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 2,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
};
// Icon URIs for category items
static const _iconPkg = 'com.collotsspot.ensemble';
static final _iconHome = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_home');
static final _iconMusic = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_music');
static final _iconBook = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_book');
static final _iconPodcast = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_podcast');
static final _iconRadio = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_radio');
static final _iconArtist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_artist');
static final _iconAlbum = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_album');
static final _iconPlaylist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_playlist');
static final _iconFavorite = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_favorite');
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
final provider = _autoProvider;
if (provider == null) {
_logger.log('AndroidAuto: getChildren("$parentMediaId") — provider not set yet');
return [];
}
// Ensure provider is connected — AA may browse before app is fully ready
if (!provider.isConnected) {
_logger.log('AndroidAuto: provider disconnected, starting reconnect');
// Don't await — reconnection can take seconds and AA may timeout
// getChildren. Cached data is served immediately; live API calls
// inside builders will await their own reconnect if needed.
provider.checkAndReconnect();
}
try {
final result = await _autoGetChildren(provider, parentMediaId);
_logger.log('AndroidAuto: getChildren "$parentMediaId" → ${result.length} items');
return result;
} catch (e, st) {
_logger.log('AndroidAuto: getChildren error for "$parentMediaId": $e\n$st');
return [];
}
}
Future<List<MediaItem>> _autoGetChildren(
MusicAssistantProvider provider, String parentMediaId) async {
switch (parentMediaId) {
// Root
case AudioService.browsableRootId:
return _autoBuildRoot();
// Home — subcategory folders matching user's homescreen settings
case _autoIdHome:
return _autoBuildHome();
// Music
case _autoIdMusic:
return _autoBuildMusicCategories();
case _autoIdPlaylists:
return _autoBuildPlaylistList(provider);
case _autoIdArtists:
return _autoBuildArtistList(provider);
case _autoIdAlbums:
return _autoBuildAlbumList(provider);
case _autoIdFavorites:
return _autoBuildFavorites(provider);
// Audiobooks
case _autoIdAudiobooks:
return _autoBuildAudiobookCategories();
case _autoIdAbAuthors:
return _autoBuildAudiobookAuthorList(provider);
case _autoIdAbBooks:
return _autoBuildAudiobookList(provider);
case _autoIdAbSeries:
return _autoBuildAudiobookSeriesList(provider);
// Podcasts
case _autoIdPodcasts:
return _autoBuildPodcastList(provider);
// Radio
case _autoIdRadio:
return _autoBuildRadioList(provider);
default:
// Home row content (home|recent-albums, etc.)
if (parentMediaId.startsWith('home|')) {
return _autoBuildHomeRowContent(provider, parentMediaId.substring(5));
}
return _autoBuildDynamicChildren(provider, parentMediaId);
}
}
/// Route prefix-based dynamic media IDs (albums, playlists, artists, etc.)
Future<List<MediaItem>> _autoBuildDynamicChildren(
MusicAssistantProvider provider, String parentMediaId) async {
final parts = parentMediaId.split('|');
if (parts.length < 2) return [];
switch (parts[0]) {
case 'playlist':
if (parts.length >= 3) {
return _autoBuildPlaylistTracks(provider, parts[1], parts[2]);
}
case 'album':
if (parts.length >= 3) {
return _autoBuildAlbumTracks(provider, parts[1], parts[2]);
}
case 'artist':
final name = parts.sublist(1).join('|');
return _autoBuildArtistAlbums(provider, name);
case 'podcast':
if (parts.length >= 3) {
return _autoBuildPodcastEpisodes(provider, parts[1], parts[2]);
}
case 'ab_author':
final authorName = parts.sublist(1).join('|');
return _autoBuildAuthorAudiobooks(provider, authorName);
case 'ab_series':
final seriesPath = parts.sublist(1).join('|');
return _autoBuildSeriesAudiobooks(provider, seriesPath);
}
_logger.log('AndroidAuto: unhandled parentMediaId "$parentMediaId"');
return [];
}
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
_autoChildrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
return _autoChildrenSubjects[parentMediaId]!.stream;
}
@override
Future<void> playFromMediaId(String mediaId,
[Map<String, dynamic>? extras]) async {
final provider = _autoProvider;
if (provider == null) {
_logger.log('AndroidAuto: playFromMediaId called before provider is set');
return;
}
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) {
_logger.log('AndroidAuto: no builtin player ID — cannot play');
return;
}
// Auto-switch to the builtin (local) player when AA triggers playback
if (provider.selectedPlayer?.playerId != playerId) {
final builtinPlayer = provider.availablePlayersUnfiltered
.where((p) => p.playerId == playerId)
.firstOrNull;
if (builtinPlayer != null) {
_logger.log('AndroidAuto: switching to builtin player "${builtinPlayer.name}"');
provider.selectPlayer(builtinPlayer);
}
}
_logger.log('AndroidAuto: playFromMediaId $mediaId');
try {
if (mediaId.startsWith('track|')) {
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
final parts = mediaId.split('|');
if (parts.length < 6) return;
final tProvider = parts[1];
final tItemId = parts[2];
final ctxKey = parts.sublist(3).join('|');
final trackList = _autoTrackCache[ctxKey];
if (trackList == null || trackList.isEmpty) {
_logger.log('AndroidAuto: cache miss for $ctxKey, playing single track');
await provider.playTrack(
playerId,
ma.Track(itemId: tItemId, provider: tProvider, name: ''),
);
return;
}
final index = trackList.indexWhere(
(t) => t.provider == tProvider && t.itemId == tItemId,
);
await provider.playTracks(
playerId,
trackList,
startIndex: index < 0 ? 0 : index,
);
return;
}
if (mediaId.startsWith('radio|')) {
// Format: radio|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) await provider.checkAndReconnect();
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play radio');
return;
}
final station = provider.radioStations.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => provider.radioStationsUnfiltered.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => throw Exception('Radio station not found: $mediaId'),
),
);
await provider.api!.playRadioStation(playerId, station);
return;
}
if (mediaId.startsWith('audiobook|')) {
// Format: audiobook|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) {
await provider.checkAndReconnect();
}
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play audiobook');
return;
}
final book = SyncService.instance.cachedAudiobooks.firstWhere(
(b) => b.provider == parts[1] && b.itemId == parts[2],
orElse: () => throw Exception('Audiobook not found: $mediaId'),
);
await provider.api!.playAudiobook(playerId, book);
return;
}
if (mediaId.startsWith('podcast_ep|')) {
// Format: podcast_ep|{provider}|{itemId}
final parts = mediaId.split('|');
if (parts.length < 3) return;
if (provider.api == null) await provider.checkAndReconnect();
if (provider.api == null) {
_logger.log('AndroidAuto: no API, cannot play podcast');
return;
}
// Use provider-specific URI (e.g. spotify--xxx://podcast_episode/id)
// instead of library:// which fails for non-library items
final uri = '${parts[1]}://podcast_episode/${parts[2]}';
final episode = ma.MediaItem(
itemId: parts[2],
provider: parts[1],
name: '',
mediaType: ma.MediaType.podcastEpisode,
uri: uri,
);
_logger.log('AndroidAuto: playing podcast episode URI: $uri');
await provider.api!.playPodcastEpisode(playerId, episode);
return;
}
} catch (e) {
_logger.log('AndroidAuto: playFromMediaId error: $e');
}
}
@override
Future<List<MediaItem>> search(String query,
[Map<String, dynamic>? extras]) async {
final provider = _autoProvider;
if (provider == null || query.trim().isEmpty) return [];
try {
final results = await provider.searchWithCache(query);
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
const ctxKey = 'search||';
_autoTrackCache[ctxKey] = tracks;
return tracks
.map((t) => _autoTrackItem(provider, t, ctxKey))
.toList();
} catch (e) {
_logger.log('AndroidAuto: search error: $e');
return [];
}
}
// --- Root & category builders ---
List<MediaItem> _autoBuildRoot() {
return [
MediaItem(id: _autoIdHome, title: 'Home', playable: false,
artUri: _iconHome),
MediaItem(id: _autoIdMusic, title: 'Music', playable: false,
artUri: _iconMusic),
MediaItem(id: _autoIdAudiobooks, title: 'Audiobooks', playable: false,
artUri: _iconBook),
MediaItem(id: _autoIdPodcasts, title: 'Podcasts', playable: false,
artUri: _iconPodcast, extras: _gridHints),
MediaItem(id: _autoIdRadio, title: 'Radio', playable: false,
artUri: _iconRadio, extras: _gridHints),
];
}
// Row ID → display title mapping
static const _homeRowTitles = {
'recent-albums': 'Recent Albums',
'discover-artists': 'Discover Artists',
'discover-albums': 'Discover Albums',
'continue-listening': 'Continue Listening',
'discover-audiobooks': 'Discover Audiobooks',
'discover-series': 'Discover Series',
'favorite-albums': 'Favourite Albums',
'favorite-artists': 'Favourite Artists',
'favorite-tracks': 'Favourite Tracks',
'favorite-playlists': 'Favourite Playlists',
'favorite-radio-stations': 'Favourite Radio',
'favorite-podcasts': 'Favourite Podcasts',
};
/// Home shows subcategory folders matching the user's homescreen settings.
Future<List<MediaItem>> _autoBuildHome() async {
final rowOrder = await SettingsService.getHomeRowOrder();
final items = <MediaItem>[];
for (final rowId in rowOrder) {
if (!await _isHomeRowEnabled(rowId)) continue;
final title = _homeRowTitles[rowId];
if (title == null) continue; // skip discovery rows for now
items.add(MediaItem(
id: 'home|$rowId',
title: title,
playable: false,
extras: _gridHints,
));
}
_logger.log('AndroidAuto: Home built ${items.length} rows');
return items;
}
Future<bool> _isHomeRowEnabled(String rowId) async {
switch (rowId) {
case 'recent-albums': return SettingsService.getShowRecentAlbums();
case 'discover-artists': return SettingsService.getShowDiscoverArtists();
case 'discover-albums': return SettingsService.getShowDiscoverAlbums();
case 'continue-listening': return SettingsService.getShowContinueListeningAudiobooks();
case 'discover-audiobooks': return SettingsService.getShowDiscoverAudiobooks();
case 'discover-series': return SettingsService.getShowDiscoverSeries();
case 'favorite-albums': return SettingsService.getShowFavoriteAlbums();
case 'favorite-artists': return SettingsService.getShowFavoriteArtists();
case 'favorite-tracks': return SettingsService.getShowFavoriteTracks();
case 'favorite-playlists': return SettingsService.getShowFavoritePlaylists();
case 'favorite-radio-stations': return SettingsService.getShowFavoriteRadioStations();
case 'favorite-podcasts': return SettingsService.getShowFavoritePodcasts();
default: return false;
}
}
/// Build content for a specific home row.
Future<List<MediaItem>> _autoBuildHomeRowContent(
MusicAssistantProvider provider, String rowId) async {
switch (rowId) {
case 'recent-albums':
var albums = await provider.getRecentAlbumsWithCache();
if (albums.isEmpty) albums = SyncService.instance.cachedAlbums.take(20).toList();
return albums.take(20).map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'discover-artists':
var artists = await provider.getDiscoverArtistsWithCache();
if (artists.isEmpty) artists = SyncService.instance.cachedArtists.take(10).toList();
return artists.take(10).map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'discover-albums':
final albums = await provider.getDiscoverAlbumsWithCache();
return albums.take(20).map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'continue-listening':
final books = await provider.getInProgressAudiobooksWithCache();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name, artist: b.authorsString,
artUri: _autoArtUri(provider, b), playable: true,
)).toList();
case 'discover-audiobooks':
final books = await provider.getDiscoverAudiobooksWithCache();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name, artist: b.authorsString,
artUri: _autoArtUri(provider, b), playable: true,
)).toList();
case 'discover-series':
return _autoBuildAudiobookSeriesList(provider);
case 'favorite-albums':
final albums = await provider.getFavoriteAlbums();
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name, artist: a.artistsString,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'favorite-artists':
final artists = await provider.getFavoriteArtists();
return artists.map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a), playable: false,
)).toList();
case 'favorite-tracks':
final tracks = await provider.getFavoriteTracks();
const ctxKey = 'favs||';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
case 'favorite-playlists':
final playlists = await provider.getFavoritePlaylists();
return playlists.map((p) => MediaItem(
id: 'playlist|${p.provider}|${p.itemId}',
title: p.name, artist: p.owner,
artUri: _autoArtUri(provider, p), playable: false,
)).toList();
case 'favorite-radio-stations':
final stations = await provider.getFavoriteRadioStations();
return stations.map((s) => MediaItem(
id: 'radio|${s.provider}|${s.itemId}',
title: s.name,
artUri: _autoArtUri(provider, s), playable: true,
)).toList();
case 'favorite-podcasts':
// Use cached podcasts filtered by favorite
final podcasts = SyncService.instance.cachedPodcasts
.where((p) => p.favorite == true).toList();
return podcasts.map((p) => MediaItem(
id: 'podcast|${p.provider}|${p.itemId}',
title: p.name,
artUri: _autoArtUri(provider, p), playable: false,
)).toList();
default:
return [];
}
}
List<MediaItem> _autoBuildMusicCategories() {
return [
MediaItem(id: _autoIdArtists, title: 'Artists', playable: false,
artUri: _iconArtist, extras: _gridHints),
MediaItem(id: _autoIdAlbums, title: 'Albums', playable: false,
artUri: _iconAlbum, extras: _gridHints),
MediaItem(id: _autoIdPlaylists, title: 'Playlists', playable: false,
artUri: _iconPlaylist, extras: _gridHints),
MediaItem(id: _autoIdFavorites, title: 'Favourites', playable: false,
artUri: _iconFavorite),
];
}
List<MediaItem> _autoBuildAudiobookCategories() {
return [
MediaItem(id: _autoIdAbAuthors, title: 'Authors', playable: false,
artUri: _iconArtist),
MediaItem(id: _autoIdAbBooks, title: 'Books', playable: false,
artUri: _iconBook, extras: _gridHints),
MediaItem(id: _autoIdAbSeries, title: 'Series', playable: false,
artUri: _iconBook, extras: _gridHints),
];
}
// --- Music builders ---
Future<List<MediaItem>> _autoBuildFavorites(
MusicAssistantProvider provider) async {
final tracks = await provider.getFavoriteTracks();
_logger.log('AndroidAuto: Favorites returned ${tracks.length} tracks');
const ctxKey = 'favs||';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
List<MediaItem> _autoBuildPlaylistList(MusicAssistantProvider provider) {
final playlists = SyncService.instance.cachedPlaylists;
_logger.log('AndroidAuto: Playlists: ${playlists.length}');
return playlists.map((p) => MediaItem(
id: 'playlist|${p.provider}|${p.itemId}',
title: p.name,
artist: p.owner,
artUri: _autoArtUri(provider, p),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildPlaylistTracks(
MusicAssistantProvider provider, String plProvider, String plItemId) async {
final tracks =
await provider.getPlaylistTracksWithCache(plProvider, plItemId);
final ctxKey = 'plist|$plProvider|$plItemId';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
List<MediaItem> _autoBuildArtistList(MusicAssistantProvider provider) {
final artists = SyncService.instance.cachedArtists;
_logger.log('AndroidAuto: Artists: ${artists.length}');
return artists.map((a) => MediaItem(
id: 'artist|${a.name}',
title: a.name,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildArtistAlbums(
MusicAssistantProvider provider, String artistName) async {
var albums = await provider.getArtistAlbumsWithCache(artistName);
if (albums.isEmpty) {
// Fallback to library if API unavailable
albums = provider.getArtistAlbumsFromLibrary(artistName);
}
_logger.log('AndroidAuto: Artist "$artistName" albums: ${albums.length}');
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name,
artist: a.artistsString,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
List<MediaItem> _autoBuildAlbumList(MusicAssistantProvider provider) {
final albums = SyncService.instance.cachedAlbums;
_logger.log('AndroidAuto: Albums: ${albums.length}');
return albums.map((a) => MediaItem(
id: 'album|${a.provider}|${a.itemId}',
title: a.name,
artist: a.artistsString,
artUri: _autoArtUri(provider, a),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildAlbumTracks(
MusicAssistantProvider provider, String alProvider, String alItemId) async {
final tracks =
await provider.getAlbumTracksWithCache(alProvider, alItemId);
_logger.log('AndroidAuto: Album $alProvider/$alItemId tracks: ${tracks.length}');
final ctxKey = 'album|$alProvider|$alItemId';
_autoTrackCache[ctxKey] = tracks;
return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList();
}
// --- Audiobook builders ---
List<MediaItem> _autoBuildAudiobookAuthorList(
MusicAssistantProvider provider) {
final books = SyncService.instance.cachedAudiobooks;
final seen = <String>{};
final items = <MediaItem>[];
for (final b in books) {
final author = b.authorsString;
if (seen.add(author)) {
items.add(MediaItem(
id: 'ab_author|$author',
title: author,
artUri: _autoArtUri(provider, b),
playable: false,
));
}
}
_logger.log('AndroidAuto: Audiobook authors: ${items.length}');
return items;
}
List<MediaItem> _autoBuildAuthorAudiobooks(
MusicAssistantProvider provider, String authorName) {
final books = SyncService.instance.cachedAudiobooks
.where((b) => b.authorsString == authorName)
.toList();
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
Future<List<MediaItem>> _autoBuildAudiobookList(MusicAssistantProvider provider) async {
var books = SyncService.instance.cachedAudiobooks;
if (books.isEmpty) {
// Sync cache may not be loaded yet — wait for it
_logger.log('AndroidAuto: cachedAudiobooks empty, loading from cache');
await SyncService.instance.loadFromCache();
books = SyncService.instance.cachedAudiobooks;
}
_logger.log('AndroidAuto: Books: ${books.length}');
// Sort alphabetically and cap to avoid overwhelming Android Auto
books.sort((a, b) => a.name.compareTo(b.name));
return books.take(500).map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
Future<List<MediaItem>> _autoBuildAudiobookSeriesList(
MusicAssistantProvider provider) async {
final series = await provider.getDiscoverSeriesWithCache();
_logger.log('AndroidAuto: Audiobook series: ${series.length}');
final items = <MediaItem>[];
for (final s in series) {
// Use first book's artwork instead of series mosaic thumbnail
Uri? artUri;
var cachedBooks = provider.getCachedSeriesAudiobooks(s.id);
if (cachedBooks == null || cachedBooks.isEmpty) {
// Fetch books for this series (result is cached for future use)
try {
cachedBooks = await provider.getSeriesAudiobooksWithCache(s.id);
} catch (_) {}
}
if (cachedBooks != null && cachedBooks.isNotEmpty) {
artUri = _autoArtUri(provider, cachedBooks.first);
}
items.add(MediaItem(
id: 'ab_series|${s.id}',
title: s.name,
artUri: artUri,
playable: false,
));
}
return items;
}
Future<List<MediaItem>> _autoBuildSeriesAudiobooks(
MusicAssistantProvider provider, String seriesPath) async {
final books = await provider.getSeriesAudiobooksWithCache(seriesPath);
return books.map((b) => MediaItem(
id: 'audiobook|${b.provider}|${b.itemId}',
title: b.name,
artist: b.authorsString,
artUri: _autoArtUri(provider, b),
playable: true,
)).toList();
}
// --- Podcast builders ---
List<MediaItem> _autoBuildPodcastList(MusicAssistantProvider provider) {
final podcasts = SyncService.instance.cachedPodcasts;
_logger.log('AndroidAuto: Podcasts: ${podcasts.length}');
return podcasts.map((p) => MediaItem(
id: 'podcast|${p.provider}|${p.itemId}',
title: p.name,
artUri: _autoArtUri(provider, p),
playable: false,
)).toList();
}
Future<List<MediaItem>> _autoBuildPodcastEpisodes(
MusicAssistantProvider provider, String podProvider, String podItemId) async {
final episodes = await provider.getPodcastEpisodesWithCache(
podItemId,
provider: podProvider,
);
return episodes.map((e) => MediaItem(
id: 'podcast_ep|${e.provider}|${e.itemId}',
title: e.name,
duration: e.duration,
artUri: _autoArtUri(provider, e),
playable: true,
)).toList();
}
// --- Radio builder ---
Future<List<MediaItem>> _autoBuildRadioList(MusicAssistantProvider provider) async {
// Radio stations are lazily loaded — trigger load if empty
if (provider.radioStations.isEmpty) {
_logger.log('AndroidAuto: Radio empty, loading stations');
await provider.loadRadioStations();
}
final stations = provider.radioStations;
_logger.log('AndroidAuto: Radio stations: ${stations.length}');
return stations.map((s) => MediaItem(
id: 'radio|${s.provider}|${s.itemId}',
title: s.name,
artUri: _autoArtUri(provider, s),
playable: true,
)).toList();
}
// --- Helpers ---
MediaItem _autoTrackItem(
MusicAssistantProvider provider, ma.Track t, String ctxKey) {
return MediaItem(
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
title: t.name,
artist: t.artistsString,
album: t.album?.name,
duration: t.duration,
artUri: _autoArtUri(provider, t),
playable: true,
);
}
Uri? _autoArtUri(MusicAssistantProvider provider, ma.MediaItem item) {
final url = provider.getImageUrl(item, size: 256);
return url != null ? Uri.tryParse(url) : null;
}
// ---------------------------------------------------------------------------
/// Dispose of resources and cancel all subscriptions /// Dispose of resources and cancel all subscriptions
Future<void> dispose() async { Future<void> dispose() async {
await _interruptionSubscription?.cancel(); await _interruptionSubscription?.cancel();
+496 -140
View File
@@ -1,186 +1,542 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import 'no_auth_strategy.dart';
import 'basic_auth_strategy.dart';
import 'authelia_strategy.dart';
import 'ma_auth_strategy.dart';
import '../debug_logger.dart'; import '../debug_logger.dart';
import '../../constants/network.dart';
/// Simple authentication manager for Music Assistant /// Central authentication manager
/// MA now uses its own built-in authentication system /// Manages auth strategy selection, auto-detection, and credential lifecycle
class AuthManager { class AuthManager {
final _logger = DebugLogger(); final _logger = DebugLogger();
String? _accessToken; // Available auth strategies
String? _longLivedToken; final List<AuthStrategy> _strategies = [
NoAuthStrategy(),
BasicAuthStrategy(),
AutheliaStrategy(),
MusicAssistantAuthStrategy(),
];
/// Get the best available token (prefer long-lived) AuthStrategy? _currentStrategy;
String? get token => _longLivedToken ?? _accessToken; AuthCredentials? _currentCredentials;
/// Check if we have valid credentials /// Get current auth strategy (null if none selected)
bool get hasCredentials => token != null; AuthStrategy? get currentStrategy => _currentStrategy;
/// Login to Music Assistant and get an access token /// Get current credentials (null if none stored)
Future<String?> login(String serverUrl, String username, String password) async { AuthCredentials? get currentCredentials => _currentCredentials;
/// Auto-detect authentication requirements for a server
/// Returns null if server is unreachable or auth method unknown
/// Also returns the final URL used (in case of HTTP fallback) via the callback
String? _lastSuccessfulUrl;
String? get lastSuccessfulUrl => _lastSuccessfulUrl;
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
_lastSuccessfulUrl = null;
// Normalize URL
var baseUrl = _normalizeUrl(serverUrl);
_logger.log('Normalized URL: $baseUrl');
// Try detection with the initial URL
var result = await _tryDetectAuth(baseUrl);
// If HTTPS failed and we haven't tried HTTP yet, try HTTP fallback
if (result == null && baseUrl.startsWith('https://')) {
final httpUrl = baseUrl.replaceFirst('https://', 'http://');
_logger.log('🔄 HTTPS failed, trying HTTP fallback: $httpUrl');
result = await _tryDetectAuth(httpUrl);
if (result != null) {
baseUrl = httpUrl;
}
}
if (result != null) {
_lastSuccessfulUrl = baseUrl;
_logger.log('✅ Auth detection successful with URL: $baseUrl');
}
return result;
}
/// Normalize a server URL with appropriate protocol and port
/// For local IP addresses without an explicit port, defaults to MA port 8095
String _normalizeUrl(String url) {
var baseUrl = url.trim();
// Check if URL already has a port specified (before adding protocol)
// Port can be specified as host:port or after protocol as http://host:port
bool hasExplicitPort = _hasExplicitPort(baseUrl);
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Use http for local IPs, https for domains
if (_isLocalAddress(baseUrl)) {
baseUrl = 'http://$baseUrl';
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
baseUrl = 'http://$baseUrl';
_logger.log('🔗 Detected Tailscale URL, using HTTP');
} else {
baseUrl = 'https://$baseUrl';
}
}
// For local addresses without explicit port, add default MA port
// This handles the common case of MA as HAOS add-on where port 8095 is needed
if (!hasExplicitPort) {
final uri = Uri.parse(baseUrl);
if (_isLocalAddress(uri.host)) {
baseUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: NetworkConstants.defaultWsPort,
path: uri.path.isEmpty ? null : uri.path,
).toString();
_logger.log('🔗 Local address without port - defaulting to MA port ${NetworkConstants.defaultWsPort}');
}
}
return baseUrl;
}
/// Check if a URL string has an explicit port specified
bool _hasExplicitPort(String url) {
// Remove protocol if present
var hostPart = url;
if (hostPart.startsWith('http://')) {
hostPart = hostPart.substring(7);
} else if (hostPart.startsWith('https://')) {
hostPart = hostPart.substring(8);
}
// Remove path if present
final pathIndex = hostPart.indexOf('/');
if (pathIndex != -1) {
hostPart = hostPart.substring(0, pathIndex);
}
// Check for port (host:port pattern)
// Need to handle IPv6 addresses like [::1]:8095
if (hostPart.startsWith('[')) {
// IPv6 address
final closeBracket = hostPart.indexOf(']');
if (closeBracket != -1 && closeBracket < hostPart.length - 1) {
return hostPart[closeBracket + 1] == ':';
}
return false;
} else {
// IPv4 or hostname - check for colon followed by digits
final colonIndex = hostPart.lastIndexOf(':');
if (colonIndex != -1) {
final portPart = hostPart.substring(colonIndex + 1);
return int.tryParse(portPart) != null;
}
return false;
}
}
/// Check if an address is a local/private address (without protocol)
bool _isLocalAddress(String address) {
// Remove port if present for checking
var host = address;
final colonIndex = host.lastIndexOf(':');
if (colonIndex != -1 && int.tryParse(host.substring(colonIndex + 1)) != null) {
host = host.substring(0, colonIndex);
}
return host.startsWith('192.168.') ||
host.startsWith('192.') ||
host.startsWith('10.') ||
host.startsWith('172.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
// First, check if this is a Music Assistant server with native auth
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
if (maAuthResult != null) {
if (maAuthResult == 'required') {
_logger.log('✓ Detected Music Assistant native authentication');
return _strategies.firstWhere((s) => s.name == 'music_assistant');
} else if (maAuthResult == 'none') {
_logger.log('✓ Music Assistant server - no auth required');
return _strategies.firstWhere((s) => s.name == 'none');
}
}
// Try no-auth (direct connection without any auth)
_logger.log('Testing no-auth strategy...');
final canConnect = await _canConnectWithoutAuth(baseUrl);
_logger.log('No-auth result: $canConnect');
if (canConnect) {
_logger.log('✓ Server does not require authentication');
return _strategies.firstWhere((s) => s.name == 'none');
}
// Probe server for auth requirements (reverse proxy scenarios)
try { try {
final apiUrl = _buildApiUrl(serverUrl); _logger.log('Probing server for auth headers...');
_logger.log('🔐 Logging in to Music Assistant at $apiUrl'); // Test root endpoint
final probeUrl = baseUrl;
_logger.log('Probe URL: $probeUrl');
final response = await http.post( final response = await http.get(
Uri.parse(probeUrl),
).timeout(const Duration(seconds: 10));
_logger.log('Probe status: ${response.statusCode}');
// Check for redirect (common with reverse proxy auth)
if (response.statusCode == 302 || response.statusCode == 307) {
final location = response.headers['location'];
_logger.log('Redirect to: $location');
// If redirects to Authelia login, it's Authelia
if (location != null && location.contains('authelia')) {
_logger.log('✓ Detected Authelia (redirect)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
}
// Server returned 401 Unauthorized - check WWW-Authenticate header
if (response.statusCode == 401) {
final wwwAuth = response.headers['www-authenticate']?.toLowerCase();
_logger.log('401 with WWW-Authenticate: $wwwAuth');
// Check for Basic Auth
if (wwwAuth != null && wwwAuth.contains('basic')) {
_logger.log('✓ Detected Basic Authentication');
return _strategies.firstWhere((s) => s.name == 'basic');
}
// Check for Bearer token - could be MA native auth
if (wwwAuth != null && wwwAuth.contains('bearer')) {
_logger.log('✓ Detected Bearer auth - likely Music Assistant native');
return _strategies.firstWhere((s) => s.name == 'music_assistant');
}
}
// If we got here and there's any auth challenge, assume Authelia
// (since we're behind a reverse proxy that requires auth)
if (response.statusCode >= 300) {
_logger.log('Server requires authentication (status ${response.statusCode})');
_logger.log('Assuming Authelia (default for authenticated reverse proxy)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
} catch (e) {
_logger.log('✗ Auth detection error: $e');
return null; // Return null to trigger fallback
}
_logger.log('⚠️ Could not determine auth method');
return null;
}
/// Check if server is a Music Assistant server with native auth
/// Returns 'required' if auth needed, 'none' if no auth, null if not MA or error
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
try {
final uri = Uri.parse(baseUrl);
// Use the port from URL if specified, otherwise use default ports (80/443)
// Don't default to 8095 here - that's only for direct connections without reverse proxy
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api',
);
_logger.log('Checking MA API at: $apiUrl');
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl, apiUrl,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({
'command': 'auth/login', 'command': 'info',
'args': {
'username': username,
'password': password,
},
}),
).timeout(const Duration(seconds: 10));
_logger.log('Auth response status: ${response.statusCode}');
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data.containsKey('error_code')) {
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
return null;
}
final result = data['result'] as Map<String, dynamic>?;
_accessToken = result?['access_token'] as String?;
if (_accessToken != null) {
_logger.log('✓ Got access token from MA');
// Try to create a long-lived token
_longLivedToken = await _createLongLivedToken(apiUrl, _accessToken!);
return _longLivedToken ?? _accessToken;
}
_logger.log('✗ No access token in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
return null;
} catch (e) {
_logger.log('✗ Login error: $e');
return null;
}
}
/// Create a long-lived token for persistent authentication
Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
try {
_logger.log('Creating long-lived token...');
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({
'command': 'auth/create_token',
'args': {
'name': 'Ensemble Mobile App',
},
}),
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (!data.containsKey('error_code')) {
final result = data['result'] as Map<String, dynamic>?;
final token = result?['token'] as String?;
if (token != null) {
_logger.log('✓ Created long-lived token');
return token;
}
}
}
_logger.log('⚠️ Could not create long-lived token (non-fatal)');
return null;
} catch (e) {
_logger.log('⚠️ Long-lived token creation failed: $e (non-fatal)');
return null;
}
}
/// Validate a stored token
Future<bool> validateToken(String serverUrl, String storedToken) async {
try {
final apiUrl = _buildApiUrl(serverUrl);
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $storedToken',
},
body: jsonEncode({
'command': 'server/info',
}), }),
).timeout(const Duration(seconds: 5)); ).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
// 401/403 means auth required - check this first
if (response.statusCode == 401 || response.statusCode == 403) {
_logger.log('MA API returned ${response.statusCode} - auth required');
return 'required';
}
// Also check for "Authentication required" text response
if (response.body.toLowerCase().contains('authentication required')) {
_logger.log('MA API returned auth required message');
return 'required';
}
if (response.statusCode == 200) { if (response.statusCode == 200) {
// Try to parse as JSON
try {
final data = jsonDecode(response.body) as Map<String, dynamic>; final data = jsonDecode(response.body) as Map<String, dynamic>;
if (!data.containsKey('error_code')) {
_longLivedToken = storedToken; // Check if we got server info successfully (no auth required)
return true; if (data.containsKey('result')) {
final result = data['result'] as Map<String, dynamic>?;
final serverVersion = result?['server_version'] as String?;
final schemaVersion = result?['schema_version'] as int?;
if (serverVersion != null) {
_logger.log('MA server version: $serverVersion, schema: $schemaVersion');
// Schema 28+ requires auth, but if we got here without auth, it's not enabled
return 'none';
} }
} }
// Check for auth error in JSON
if (data.containsKey('error_code')) {
final errorCode = data['error_code'] as int?;
// Error 50 = AuthenticationRequired
if (errorCode == 50) {
_logger.log('MA server requires authentication (error 50)');
return 'required';
}
}
} catch (jsonError) {
_logger.log('MA API response not JSON: $jsonError');
}
}
return null;
} catch (e) {
_logger.log('MA auth check error: $e');
return null;
}
}
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
try {
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
// 200 = no auth required
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (statusCode == 200) {
return true;
}
return false; return false;
} catch (e) { } catch (e) {
_logger.log('Token validation failed: $e'); _logger.log('No-auth error: $e');
return false; return false;
} }
} }
/// Build headers for WebSocket connection /// Attempt login with specified strategy
/// Returns true if login successful and credentials stored
/// authServerUrl is optional - only used for Authelia when auth is on a different domain
Future<bool> login(
String serverUrl,
String username,
String password,
AuthStrategy strategy, {
String? authServerUrl,
}) async {
_logger.log('Attempting login with ${strategy.name} strategy');
// Use auth server URL if provided, otherwise use main server URL
final loginUrl = authServerUrl?.isNotEmpty == true ? authServerUrl! : serverUrl;
_logger.log('Login URL: $loginUrl');
final credentials = await strategy.login(loginUrl, username, password);
if (credentials != null) {
_currentStrategy = strategy;
_currentCredentials = credentials;
_logger.log('✓ Login successful with ${strategy.name}');
return true;
}
_logger.log('✗ Login failed with ${strategy.name}');
return false;
}
/// Validate current credentials are still valid
Future<bool> validateCurrentCredentials(String serverUrl) async {
if (_currentStrategy == null || _currentCredentials == null) {
return false;
}
return await _currentStrategy!.validateCredentials(
serverUrl,
_currentCredentials!,
);
}
/// Get WebSocket connection headers for current credentials
Map<String, dynamic> getWebSocketHeaders() { Map<String, dynamic> getWebSocketHeaders() {
if (token != null) { if (_currentStrategy == null || _currentCredentials == null) {
return {'Authorization': 'Bearer $token'};
}
return {}; return {};
} }
/// Build headers for HTTP streaming requests return _currentStrategy!.buildWebSocketHeaders(_currentCredentials!);
}
/// Get HTTP streaming headers for current credentials
Map<String, String> getStreamingHeaders() { Map<String, String> getStreamingHeaders() {
if (token != null) { if (_currentStrategy == null || _currentCredentials == null) {
return {'Authorization': 'Bearer $token'};
}
return {}; return {};
} }
/// Set token from external source (e.g., restored from settings) return _currentStrategy!.buildStreamingHeaders(_currentCredentials!);
void setToken(String? storedToken) {
_longLivedToken = storedToken;
} }
/// Clear authentication state /// Serialize current credentials for persistent storage
Map<String, dynamic>? serializeCredentials() {
if (_currentStrategy == null || _currentCredentials == null) {
return null;
}
return {
'strategy': _currentStrategy!.name,
'data': _currentStrategy!.serializeCredentials(_currentCredentials!),
};
}
/// Deserialize and restore credentials from persistent storage
void deserializeCredentials(Map<String, dynamic> stored) {
final strategyName = stored['strategy'] as String?;
final data = stored['data'] as Map<String, dynamic>?;
if (strategyName == null || data == null) {
return;
}
// Find matching strategy
try {
final strategy = _strategies.firstWhere((s) => s.name == strategyName);
_currentStrategy = strategy;
_currentCredentials = strategy.deserializeCredentials(data);
_logger.log('✓ Restored ${strategy.name} credentials from storage');
} catch (e) {
_logger.log('✗ Could not restore credentials: $e');
}
}
/// Clear current authentication state
void logout() { void logout() {
_accessToken = null; _currentStrategy = null;
_longLivedToken = null; _currentCredentials = null;
_logger.log('Logged out - cleared auth state'); _logger.log('Logged out - cleared auth state');
} }
/// Build API URL from server URL /// Get strategy by name (for manual selection)
Uri _buildApiUrl(String serverUrl) { AuthStrategy? getStrategyByName(String name) {
var baseUrl = serverUrl; try {
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { return _strategies.firstWhere((s) => s.name == name);
baseUrl = 'http://$baseUrl'; } catch (e) {
return null;
} }
final uri = Uri.parse(baseUrl);
return Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
path: '/api',
);
} }
} }
+42
View File
@@ -0,0 +1,42 @@
/// Authentication credentials container
class AuthCredentials {
final String strategyName;
final Map<String, dynamic> data;
AuthCredentials(this.strategyName, this.data);
}
/// Abstract authentication strategy interface
/// Implementations handle different auth methods (none, basic, Authelia, HA, etc.)
abstract class AuthStrategy {
/// Strategy name identifier (e.g., 'none', 'basic', 'authelia')
String get name;
/// Attempt login and return auth credentials/token
/// Returns null if login fails
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
);
/// Test if current credentials are still valid
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
);
/// Build headers for WebSocket connection
/// Example: {'Authorization': 'Basic xyz'} or {'Cookie': 'session=abc'}
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials);
/// Build headers for HTTP streaming requests
/// Example: {'Authorization': 'Basic xyz'}
Map<String, String> buildStreamingHeaders(AuthCredentials credentials);
/// Serialize credentials for persistent storage
Map<String, dynamic> serializeCredentials(AuthCredentials credentials);
/// Deserialize credentials from persistent storage
AuthCredentials deserializeCredentials(Map<String, dynamic> data);
}
+172
View File
@@ -0,0 +1,172 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import '../debug_logger.dart';
/// Authelia authentication strategy
/// Used when Music Assistant is behind Authelia reverse proxy authentication
/// Authelia-specific endpoints: /api/firstfactor, /api/verify
class AutheliaStrategy implements AuthStrategy {
final _logger = DebugLogger();
@override
String get name => 'authelia';
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
try {
// Normalize server URL
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
_logger.log('🔐 Attempting Authelia login to $baseUrl');
// Parse URL and construct Authelia firstfactor endpoint
final uri = Uri.parse(baseUrl);
final authUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api/firstfactor',
);
_logger.log('Auth URL: $authUrl');
// POST to Authelia's firstfactor endpoint
final response = await http.post(
authUrl,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'username': username,
'password': password,
'keepMeLoggedIn': true,
}),
).timeout(const Duration(seconds: 10));
_logger.log('Auth response status: ${response.statusCode}');
// Check for successful authentication
if (response.statusCode == 200) {
_logger.log('✓ Authentication successful');
// Extract session cookie from Set-Cookie header
final cookies = response.headers['set-cookie'];
if (cookies != null && cookies.isNotEmpty) {
_logger.log('✓ Received session cookie');
final sessionCookie = _extractSessionCookie(cookies);
if (sessionCookie != null) {
_logger.log('✓ Extracted session cookie');
return AuthCredentials('authelia', {
'session_cookie': sessionCookie,
'username': username,
});
}
}
// If 200 but no cookie, something is wrong
_logger.log('✗ No session cookie in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
_logger.log('Response body: ${response.body}');
return null;
} catch (e) {
_logger.log('✗ Login error: $e');
return null;
}
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
final sessionCookie = credentials.data['session_cookie'] as String?;
if (sessionCookie == null) return false;
try {
// Normalize server URL
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
// Parse URL and construct Authelia verify endpoint
final uri = Uri.parse(baseUrl);
final verifyUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api/verify',
);
final response = await http.get(
verifyUrl,
headers: {
'Cookie': 'authelia_session=$sessionCookie',
},
).timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (e) {
_logger.log('Auth validation failed: $e');
return false;
}
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
final sessionCookie = credentials.data['session_cookie'] as String;
return {
'Cookie': 'authelia_session=$sessionCookie',
};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
final sessionCookie = credentials.data['session_cookie'] as String;
return {
'Cookie': 'authelia_session=$sessionCookie',
};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return credentials.data;
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('authelia', data);
}
/// Extract authelia_session cookie value from Set-Cookie header
/// Migrated from auth_service.dart:81-98
String? _extractSessionCookie(String setCookieHeader) {
final cookies = setCookieHeader.split(',');
for (final cookie in cookies) {
if (cookie.trim().startsWith('authelia_session=')) {
final parts = cookie.split(';');
if (parts.isNotEmpty) {
final value = parts[0].trim();
if (value.startsWith('authelia_session=')) {
return value.substring('authelia_session='.length);
}
}
}
}
return null;
}
}
@@ -0,0 +1,82 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
/// Basic HTTP Authentication strategy
/// Used by reverse proxies (nginx, Apache, Caddy, etc.) configured with basic auth
/// Example: Authorization: Basic base64(username:password)
class BasicAuthStrategy implements AuthStrategy {
@override
String get name => 'basic';
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
// Encode credentials to base64
final credentials = base64Encode(utf8.encode('$username:$password'));
// Test credentials against server root endpoint
// (Music Assistant doesn't have /api/info)
try {
final response = await http.get(
Uri.parse(serverUrl),
headers: {'Authorization': 'Basic $credentials'},
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
return AuthCredentials('basic', {
'credentials': credentials,
'username': username, // Store for re-login if needed
});
}
} catch (e) {
return null;
}
return null;
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
final base64Creds = credentials.data['credentials'] as String?;
if (base64Creds == null) return false;
try {
final response = await http.get(
Uri.parse(serverUrl),
headers: {'Authorization': 'Basic $base64Creds'},
).timeout(const Duration(seconds: 5));
return response.statusCode == 200;
} catch (e) {
return false;
}
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
final base64Creds = credentials.data['credentials'] as String;
return {'Authorization': 'Basic $base64Creds'};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
final base64Creds = credentials.data['credentials'] as String;
return {'Authorization': 'Basic $base64Creds'};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return credentials.data;
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('basic', data);
}
}
+254
View File
@@ -0,0 +1,254 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import '../debug_logger.dart';
/// Music Assistant native authentication strategy
/// Used when MA server has built-in authentication enabled (schema 28+)
///
/// Flow:
/// 1. Connect WebSocket (receives server_info with auth requirements)
/// 2. If auth required, login with username/password to get access token
/// 3. Send 'auth' command with token to authenticate the session
/// 4. Optionally create long-lived token for persistent storage
class MusicAssistantAuthStrategy implements AuthStrategy {
final _logger = DebugLogger();
@override
String get name => 'music_assistant';
/// Login to Music Assistant and get an access token
/// This is called AFTER WebSocket connection is established
/// Returns credentials containing the access token
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
try {
// Normalize server URL
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Determine protocol based on URL pattern
if (baseUrl.startsWith('192.') ||
baseUrl.startsWith('10.') ||
baseUrl.startsWith('172.') ||
baseUrl == 'localhost' ||
baseUrl.startsWith('127.')) {
baseUrl = 'http://$baseUrl';
} else {
baseUrl = 'https://$baseUrl';
}
}
_logger.log('🔐 Attempting Music Assistant login to $baseUrl');
// MA auth endpoint - POST /api with JSON-RPC style command
final uri = Uri.parse(baseUrl);
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
path: '/api',
);
_logger.log('Auth API URL: $apiUrl');
// Login request to get access token
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'command': 'auth/login',
'args': {
'username': username,
'password': password,
},
}),
).timeout(const Duration(seconds: 10));
_logger.log('Auth response status: ${response.statusCode}');
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check for error in response
if (data.containsKey('error_code')) {
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
return null;
}
// Extract access token from result
final result = data['result'] as Map<String, dynamic>?;
final accessToken = result?['access_token'] as String?;
if (accessToken != null) {
_logger.log('✓ Got access token from MA');
// Now try to create a long-lived token for persistent auth
final longLivedToken = await _createLongLivedToken(apiUrl, accessToken);
return AuthCredentials('music_assistant', {
'access_token': accessToken,
'long_lived_token': longLivedToken,
'username': username,
'server_url': baseUrl,
});
}
_logger.log('✗ No access token in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
_logger.log('Response body: ${response.body}');
return null;
} catch (e) {
_logger.log('✗ Login error: $e');
return null;
}
}
/// Create a long-lived token for persistent authentication
Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
try {
_logger.log('Creating long-lived token...');
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({
'command': 'auth/create_token',
'args': {
'name': 'Ensemble Mobile App',
},
}),
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (!data.containsKey('error_code')) {
final result = data['result'] as Map<String, dynamic>?;
final token = result?['token'] as String?;
if (token != null) {
_logger.log('✓ Created long-lived token');
return token;
}
}
}
_logger.log('⚠️ Could not create long-lived token (non-fatal)');
return null;
} catch (e) {
_logger.log('⚠️ Long-lived token creation failed: $e (non-fatal)');
return null;
}
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
// Try long-lived token first, then access token
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
final token = longLivedToken ?? accessToken;
if (token == null) return false;
try {
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'https://$baseUrl';
}
final uri = Uri.parse(baseUrl);
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
path: '/api',
);
// Test token by getting server info
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
'command': 'server/info',
}),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return !data.containsKey('error_code');
}
return false;
} catch (e) {
_logger.log('Token validation failed: $e');
return false;
}
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
// MA auth happens AFTER WebSocket connection, not via headers
// But we can still include token if available for initial handshake
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
final token = longLivedToken ?? accessToken;
if (token != null) {
return {
'Authorization': 'Bearer $token',
};
}
return {};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
final token = longLivedToken ?? accessToken;
if (token != null) {
return {
'Authorization': 'Bearer $token',
};
}
return {};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return credentials.data;
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('music_assistant', data);
}
/// Get the best available token (prefer long-lived)
String? getToken(AuthCredentials credentials) {
final longLivedToken = credentials.data['long_lived_token'] as String?;
final accessToken = credentials.data['access_token'] as String?;
return longLivedToken ?? accessToken;
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
/// No authentication strategy for open Music Assistant servers
/// Typical scenarios:
/// - Local network deployment (http://192.168.1.100:8095)
/// - Home network without reverse proxy
/// - Development servers
class NoAuthStrategy implements AuthStrategy {
@override
String get name => 'none';
@override
Future<AuthCredentials?> login(
String serverUrl,
String username,
String password,
) async {
// No authentication required - just test server connectivity
try {
final response = await http.get(
Uri.parse('$serverUrl/api/info'),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
return AuthCredentials('none', {});
}
} catch (e) {
return null;
}
return null;
}
@override
Future<bool> validateCredentials(
String serverUrl,
AuthCredentials credentials,
) async {
// Always valid for no-auth
return true;
}
@override
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
// No auth headers needed
return {};
}
@override
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
// No auth headers needed
return {};
}
@override
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
return {};
}
@override
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
return AuthCredentials('none', {});
}
}
+1 -299
View File
@@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../models/recommendation_folder.dart';
import '../services/debug_logger.dart'; import '../services/debug_logger.dart';
import '../services/database_service.dart'; import '../services/database_service.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
@@ -11,7 +10,6 @@ import '../constants/timings.dart';
class CacheService { class CacheService {
final DebugLogger _logger = DebugLogger(); final DebugLogger _logger = DebugLogger();
bool _homeRowsLoaded = false; bool _homeRowsLoaded = false;
Future<void>? _homeRowsLoadFuture;
// Cache size limits to prevent unbounded memory growth // Cache size limits to prevent unbounded memory growth
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
@@ -22,14 +20,12 @@ class CacheService {
List<Album>? _cachedRecentAlbums; List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists; List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums; List<Album>? _cachedDiscoverAlbums;
List<RecommendationFolder>? _cachedDiscoveryFolders;
List<Audiobook>? _cachedInProgressAudiobooks; List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks; List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries; List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched; DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched; DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched; DateTime? _discoverAlbumsLastFetched;
DateTime? _discoveryFoldersLastFetched;
DateTime? _inProgressAudiobooksLastFetched; DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched; DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched; DateTime? _discoverSeriesLastFetched;
@@ -55,21 +51,10 @@ class CacheService {
final Map<String, Track?> _playerTrackCache = {}; final Map<String, Track?> _playerTrackCache = {};
final Map<String, DateTime> _playerTrackCacheTime = {}; 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 // HOME SCREEN ROW CACHING
// ============================================================================ // ============================================================================
/// Ensure home rows are loaded from database before accessing cache
/// This ensures the cache is populated on first access if not already loaded
Future<void> ensureHomeRowsLoaded() async {
if (!_homeRowsLoaded) {
await loadHomeRowsFromDatabase();
}
}
/// Check if recent albums cache is valid /// Check if recent albums cache is valid
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) { bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false; if (forceRefresh) return false;
@@ -84,11 +69,6 @@ class CacheService {
/// Set cached recent albums /// Set cached recent albums
void setCachedRecentAlbums(List<Album> albums) { void setCachedRecentAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty recent albums result');
return;
}
_cachedRecentAlbums = albums; _cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now(); _recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums'); _logger.log('✅ Cached ${albums.length} recent albums');
@@ -110,11 +90,6 @@ class CacheService {
/// Set cached discover artists /// Set cached discover artists
void setCachedDiscoverArtists(List<Artist> artists) { void setCachedDiscoverArtists(List<Artist> artists) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (artists.isEmpty) {
_logger.log('⚠️ Not caching empty discover artists result');
return;
}
_cachedDiscoverArtists = artists; _cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now(); _discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists'); _logger.log('✅ Cached ${artists.length} discover artists');
@@ -136,11 +111,6 @@ class CacheService {
/// Set cached discover albums /// Set cached discover albums
void setCachedDiscoverAlbums(List<Album> albums) { void setCachedDiscoverAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty discover albums result');
return;
}
_cachedDiscoverAlbums = albums; _cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now(); _discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums'); _logger.log('✅ Cached ${albums.length} discover albums');
@@ -148,32 +118,6 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList()); _persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
} }
/// Check if discovery folders cache is valid
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoveryFolders != null &&
_discoveryFoldersLastFetched != null &&
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discovery folders
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
/// Set cached discovery folders
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (folders.isEmpty) {
_logger.log('⚠️ Not caching empty discovery folders result');
return;
}
_cachedDiscoveryFolders = folders;
_discoveryFoldersLastFetched = DateTime.now();
_logger.log('✅ Cached ${folders.length} discovery folders');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid /// Check if in-progress audiobooks cache is valid
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) { bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false; if (forceRefresh) return false;
@@ -188,11 +132,6 @@ class CacheService {
/// Set cached in-progress audiobooks /// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) { void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty in-progress audiobooks result');
return;
}
_cachedInProgressAudiobooks = audiobooks; _cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now(); _inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks'); _logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
@@ -212,11 +151,6 @@ class CacheService {
/// Set cached discover audiobooks /// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) { void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty discover audiobooks result');
return;
}
_cachedDiscoverAudiobooks = audiobooks; _cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now(); _discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks'); _logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
@@ -236,11 +170,6 @@ class CacheService {
/// Set cached discover series /// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> series) { void setCachedDiscoverSeries(List<AudiobookSeries> series) {
// Don't cache empty results
if (series.isEmpty) {
_logger.log('⚠️ Not caching empty discover series result');
return;
}
_cachedDiscoverSeries = series; _cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now(); _discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series'); _logger.log('✅ Cached ${series.length} discover series');
@@ -262,7 +191,6 @@ class CacheService {
_recentAlbumsLastFetched = null; _recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null; _discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null; _discoverAlbumsLastFetched = null;
_discoveryFoldersLastFetched = null;
_inProgressAudiobooksLastFetched = null; _inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null; _discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null; _discoverSeriesLastFetched = null;
@@ -322,7 +250,6 @@ class CacheService {
_albumTracksCacheTime[cacheKey] = DateTime.now(); _albumTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize); _evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey'); _logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
_persistDetailTracks('album', cacheKey, tracks);
} }
/// Invalidate album tracks cache for a specific album /// Invalidate album tracks cache for a specific album
@@ -357,7 +284,6 @@ class CacheService {
_playlistTracksCacheTime[cacheKey] = DateTime.now(); _playlistTracksCacheTime[cacheKey] = DateTime.now();
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize); _evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey'); _logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
_persistDetailTracks('playlist', cacheKey, tracks);
} }
/// Invalidate playlist tracks cache for a specific playlist /// Invalidate playlist tracks cache for a specific playlist
@@ -489,14 +415,6 @@ class CacheService {
_playerTrackCacheTime.remove(playerId); _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 // CLEAR ALL
// ============================================================================ // ============================================================================
@@ -513,21 +431,7 @@ class CacheService {
_searchCacheTime.clear(); _searchCacheTime.clear();
_playerTrackCache.clear(); _playerTrackCache.clear();
_playerTrackCacheTime.clear(); _playerTrackCacheTime.clear();
_audiobookDetailCache.clear();
_audiobookDetailCacheTime.clear();
_podcastEpisodesCache.clear();
_podcastEpisodesCacheTime.clear();
_logger.log('🗑️ All detail caches cleared'); _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 /// Clear all caches
@@ -544,22 +448,10 @@ class CacheService {
// ============================================================================ // ============================================================================
/// Load home row data from database for instant display on startup /// Load home row data from database for instant display on startup
/// Returns the existing future if already loading, to prevent duplicate loads
Future<void> loadHomeRowsFromDatabase() async { Future<void> loadHomeRowsFromDatabase() async {
if (_homeRowsLoadFuture != null) {
// Already loading, return the existing future
return _homeRowsLoadFuture!;
}
if (_homeRowsLoaded) return; if (_homeRowsLoaded) return;
_homeRowsLoaded = true;
// Create the load future
_homeRowsLoadFuture = _doLoadHomeRowsFromDatabase();
await _homeRowsLoadFuture;
_homeRowsLoadFuture = null; // Clear after completion
}
/// Internal implementation of home row loading
Future<void> _doLoadHomeRowsFromDatabase() async {
try { try {
if (!DatabaseService.instance.isInitialized) return; if (!DatabaseService.instance.isInitialized) return;
@@ -607,25 +499,8 @@ class CacheService {
_logger.log('⚠️ Failed to parse discover albums: $e'); _logger.log('⚠️ Failed to parse discover albums: $e');
} }
} }
// Load discovery folders
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
if (foldersData != null) {
try {
final items = (jsonDecode(foldersData.itemsJson) as List)
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoveryFolders = items;
_discoveryFoldersLastFetched = foldersData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discovery folders from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discovery folders: $e');
}
}
} catch (e) { } catch (e) {
_logger.log('⚠️ Error loading home rows from database: $e'); _logger.log('⚠️ Error loading home rows from database: $e');
} finally {
_homeRowsLoaded = true;
} }
} }
@@ -671,177 +546,4 @@ class CacheService {
_logger.log('🗑️ LRU evicted ${keysToRemove.length} cache entries'); _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;
}
}
} }

Some files were not shown because too many files have changed in this diff Show More