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
77 changed files with 8099 additions and 11204 deletions
+15 -1
View File
@@ -127,11 +127,25 @@ To find the correct URL:
If you run Music Assistant as a Home Assistant add-on:
- Use the IP address of your Home Assistant server
- Enter `8095` in the port field
- Do **not** use your Home Assistant URL or ingress URL
### 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
-41
View File
@@ -2,33 +2,6 @@
## v2.9.2-beta (TBD)
### New Features
#### Context Menus
- **Long-press context menus on all media items**: Albums, artists, tracks, playlists, podcasts, and radio stations now all support long-press to show a context menu with Play, Add to Library, and Favorite options.
#### Podcasts
- **Podcast episode detail screen**: Tap "View Details" from episode context menu to see full scrollable description, publish date, duration, with Play, Play On, and Add to Queue actions.
- **Episode sorting**: Sort podcast episodes by newest first, oldest first, alphabetical, or duration.
#### Audio Quality
- **Hi-res audio badge**: Track listings now show a hi-res badge when high quality audio is available.
### Improvements
#### UI/UX
- **New library icon**: Replaced confusing library icon with clearer book icon (book_2) across bottom nav, search filter, detail screens, and context menus. Filled when in library, outlined when not.
- **Player selector**: Replaced popup dialog with player select overlay for queue actions.
- **Search library filter**: Filled icon indicates active filter state without background highlight.
- **Context menu polish**: Slightly larger buttons (46px), wider menu (185px), consistent text colors that don't pick up adaptive theming.
- **Navigation transitions**: Smoother fade/slide animations between screens.
- **Hero animations**: Podcast episode images animate smoothly to detail screen.
#### Performance
- **Library list optimization**: Standardized list item heights with itemExtent, reduced image cache sizes for smoother scrolling.
- **Palette extraction**: Moved to isolate for better UI responsiveness during color extraction.
- **Provider selects**: Optimized provider state watching to reduce unnecessary rebuilds.
### Bug Fixes
#### Audiobooks
@@ -56,23 +29,9 @@
#### Provider Management
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
#### Playback
- **Fixed Spotify external source detection** (issue #27): Properly detects when Spotify is the music provider.
- **Fixed foreground service not stopping** (issue #51): Auto-stops after 30 minutes idle.
- **Fixed Sendspin proxy auth**: Now works correctly for local network connections.
#### UI Fixes
- **Fixed nav bar hidden when no player selected**: Navigation bar now always visible.
- **Fixed Flutter 3.38 slider grey rectangles**: Volume and seek sliders render correctly.
- **Fixed library tab scroll position**: Maintains position when switching tabs.
### Technical Changes
- Flutter 3.38 compatibility
- Simplified authentication using MA built-in auth
- Added `material_symbols_icons` package for book_2 icon
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
- Provider filter now validates against available providers when toggling, not just on initial load
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
- Options menu provider toggles now use Consumer to watch Provider state directly
- Centralized player selector logic in GlobalPlayerOverlay.showPlayerSelectorForAction()
@@ -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
-6
View File
@@ -32,12 +32,6 @@ class HeroTags {
/// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_';
/// Podcast episode cover hero tag prefix
static const String podcastEpisodeCover = 'podcast_episode_cover_';
/// Podcast episode title hero tag prefix
static const String podcastEpisodeTitle = 'podcast_episode_title_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
+52 -32
View File
@@ -51,7 +51,7 @@
"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": {
"description": "Description for port field"
},
@@ -604,11 +604,6 @@
"tracksAddedToQueue": "Tracks added to queue",
"@tracksAddedToQueue": {
"description": "Snackbar message for tracks added to queue"
},
"trackAddedToQueue": "Track added to queue",
"@trackAddedToQueue": {
"description": "Confirmation when tracks added"
},
@@ -1074,10 +1069,6 @@
"sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year",
"sortBySeriesOrder": "Sort by series order",
"sortByDuration": "Sort by duration",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"viewDetails": "View details",
"listView": "List view",
"gridView": "Grid view",
"noBooksInSeries": "No books found in this series",
@@ -1096,12 +1087,11 @@
"showFavoritePodcasts": "Show a row of your favorite podcasts",
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
"favorite": "Favorite",
"addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library",
"addToLibrary": "Add",
"addToLibrary": "Add to library",
"unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all",
@@ -1217,21 +1207,11 @@
"description": "Bottom sheet title for adding album to queue"
},
"addToQueue": "Add to queue",
"@addToQueue": {
"description": "Add item to queue on selected player"
},
"addToQueueOn": "Add to queue on...",
"@addToQueueOn": {
"description": "Bottom sheet title for adding to queue"
},
"startRadio": "Start radio",
"@startRadio": {
"description": "Start radio on selected player"
},
"startRadioOn": "Start {name} radio on...",
"@startRadioOn": {
"description": "Bottom sheet title for starting radio",
@@ -1377,16 +1357,6 @@
"description": "Description for show hints toggle"
},
"volumePrecisionMode": "Volume Precision Mode",
"@volumePrecisionMode": {
"description": "Setting for fine volume control"
},
"volumePrecisionModeDescription": "Hold still while adjusting volume for fine control",
"@volumePrecisionModeDescription": {
"description": "Description for volume precision mode setting"
},
"display": "Display",
"@display": {
"description": "Settings section title for display options"
@@ -1683,5 +1653,55 @@
"selectPlayerToAddToQueue": "Select player to add to queue",
"@selectPlayerToAddToQueue": {
"description": "Hint for selecting player to add to queue"
},
"localConnection": "Server URL",
"@localConnection": {
"description": "Direct connection mode toggle label"
},
"remoteConnection": "Remote Access ID",
"@remoteConnection": {
"description": "HA Remote Access connection mode toggle label"
},
"remoteAccessId": "Remote Access ID",
"@remoteAccessId": {
"description": "Remote access ID field label"
},
"remoteAccessIdHint": "Find this in Music Assistant Settings → Remote Access",
"@remoteAccessIdHint": {
"description": "Hint for remote access ID field"
},
"remoteAccessNote": "For Home Assistant add-on users only",
"@remoteAccessNote": {
"description": "Note about remote access availability"
},
"pleaseEnterRemoteId": "Please enter your Remote Access ID",
"@pleaseEnterRemoteId": {
"description": "Validation message for remote access ID"
},
"remoteConnectionFailed": "Remote connection failed. Please check your Remote Access ID and credentials.",
"@remoteConnectionFailed": {
"description": "Error message when remote connection fails"
},
"scanQrCode": "Scan QR Code",
"@scanQrCode": {
"description": "Title for QR code scanner screen"
},
"pointCameraAtQr": "Point your camera at the Remote Access QR code in Music Assistant settings",
"@pointCameraAtQr": {
"description": "Instructions for scanning QR code"
},
"cameraPermissionDenied": "Camera permission is required to scan QR codes",
"@cameraPermissionDenied": {
"description": "Error message when camera permission is denied"
}
}
+63 -63
View File
@@ -164,7 +164,7 @@ abstract class S {
/// Description for port field
///
/// 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;
/// Username field label
@@ -761,18 +761,12 @@ abstract class S {
/// **'Album added to queue'**
String get albumAddedToQueue;
/// Snackbar message for tracks added to queue
/// Confirmation when tracks added
///
/// In en, this message translates to:
/// **'Tracks added to queue'**
String get tracksAddedToQueue;
/// Confirmation when tracks added
///
/// In en, this message translates to:
/// **'Track added to queue'**
String get trackAddedToQueue;
/// Play button label
///
/// In en, this message translates to:
@@ -1235,30 +1229,6 @@ abstract class S {
/// **'Sort by series order'**
String get sortBySeriesOrder;
/// No description provided for @sortByDuration.
///
/// In en, this message translates to:
/// **'Sort by duration'**
String get sortByDuration;
/// No description provided for @newestFirst.
///
/// In en, this message translates to:
/// **'Newest first'**
String get newestFirst;
/// No description provided for @oldestFirst.
///
/// In en, this message translates to:
/// **'Oldest first'**
String get oldestFirst;
/// No description provided for @viewDetails.
///
/// In en, this message translates to:
/// **'View details'**
String get viewDetails;
/// No description provided for @listView.
///
/// In en, this message translates to:
@@ -1367,12 +1337,6 @@ abstract class S {
/// **'Choose which rows to display on the home screen'**
String get chooseHomeScreenRows;
/// No description provided for @favorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get favorite;
/// No description provided for @addedToFavorites.
///
/// In en, this message translates to:
@@ -1400,7 +1364,7 @@ abstract class S {
/// No description provided for @addToLibrary.
///
/// In en, this message translates to:
/// **'Add'**
/// **'Add to library'**
String get addToLibrary;
/// No description provided for @unknown.
@@ -1709,24 +1673,12 @@ abstract class S {
/// **'Add album to queue on...'**
String get addAlbumToQueueOn;
/// Add item to queue on selected player
///
/// In en, this message translates to:
/// **'Add to queue'**
String get addToQueue;
/// Bottom sheet title for adding to queue
///
/// In en, this message translates to:
/// **'Add to queue on...'**
String get addToQueueOn;
/// Start radio on selected player
///
/// In en, this message translates to:
/// **'Start radio'**
String get startRadio;
/// Bottom sheet title for starting radio
///
/// In en, this message translates to:
@@ -1883,18 +1835,6 @@ abstract class S {
/// **'Display helpful tips for discovering features'**
String get showHintsDescription;
/// Setting for fine volume control
///
/// In en, this message translates to:
/// **'Volume Precision Mode'**
String get volumePrecisionMode;
/// Description for volume precision mode setting
///
/// In en, this message translates to:
/// **'Hold still while adjusting volume for fine control'**
String get volumePrecisionModeDescription;
/// Settings section title for display options
///
/// In en, this message translates to:
@@ -2182,6 +2122,66 @@ abstract class S {
/// In en, this message translates to:
/// **'Select player to add to queue'**
String get selectPlayerToAddToQueue;
/// Direct connection mode toggle label
///
/// In en, this message translates to:
/// **'Server URL'**
String get localConnection;
/// HA Remote Access connection mode toggle label
///
/// In en, this message translates to:
/// **'Remote Access ID'**
String get remoteConnection;
/// Remote access ID field label
///
/// In en, this message translates to:
/// **'Remote Access ID'**
String get remoteAccessId;
/// Hint for remote access ID field
///
/// In en, this message translates to:
/// **'Find this in Music Assistant Settings → Remote Access'**
String get remoteAccessIdHint;
/// Note about remote access availability
///
/// In en, this message translates to:
/// **'For Home Assistant add-on users only'**
String get remoteAccessNote;
/// Validation message for remote access ID
///
/// In en, this message translates to:
/// **'Please enter your Remote Access ID'**
String get pleaseEnterRemoteId;
/// Error message when remote connection fails
///
/// In en, this message translates to:
/// **'Remote connection failed. Please check your Remote Access ID and credentials.'**
String get remoteConnectionFailed;
/// Title for QR code scanner screen
///
/// In en, this message translates to:
/// **'Scan QR Code'**
String get scanQrCode;
/// Instructions for scanning QR code
///
/// In en, this message translates to:
/// **'Point your camera at the Remote Access QR code in Music Assistant settings'**
String get pointCameraAtQr;
/// Error message when camera permission is denied
///
/// In en, this message translates to:
/// **'Camera permission is required to scan QR codes'**
String get cameraPermissionDenied;
}
class _SDelegate extends LocalizationsDelegate<S> {
+35 -32
View File
@@ -373,9 +373,6 @@ class SDe extends S {
@override
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Abspielen';
@@ -649,18 +646,6 @@ class SDe extends S {
@override
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Listenansicht';
@@ -720,9 +705,6 @@ class SDe extends S {
String get chooseHomeScreenRows =>
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
@@ -736,7 +718,7 @@ class SDe extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add';
String get addToLibrary => 'Add to library';
@override
String get unknown => 'Unbekannt';
@@ -905,15 +887,9 @@ class SDe extends S {
@override
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return '$name Radio starten auf...';
@@ -1008,13 +984,6 @@ class SDe extends S {
String get showHintsDescription =>
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1186,4 +1155,38 @@ class SDe extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
@override
String get remoteConnection => 'Remote Access ID';
@override
String get remoteAccessId => 'Remote Access ID';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
@override
String get scanQrCode => 'Scan QR Code';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
}
+37 -33
View File
@@ -39,7 +39,8 @@ class SEn extends S {
String get portHint => 'e.g., 8095 or leave blank';
@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
String get username => 'Username';
@@ -368,9 +369,6 @@ class SEn extends S {
@override
String get tracksAddedToQueue => 'Tracks added to queue';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Play';
@@ -643,18 +641,6 @@ class SEn extends S {
@override
String get sortBySeriesOrder => 'Sort by series order';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'List view';
@@ -712,9 +698,6 @@ class SEn extends S {
String get chooseHomeScreenRows =>
'Choose which rows to display on the home screen';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Added to favorites';
@@ -728,7 +711,7 @@ class SEn extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add';
String get addToLibrary => 'Add to library';
@override
String get unknown => 'Unknown';
@@ -896,15 +879,9 @@ class SEn extends S {
@override
String get addAlbumToQueueOn => 'Add album to queue on...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Add to queue on...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Start $name radio on...';
@@ -999,13 +976,6 @@ class SEn extends S {
String get showHintsDescription =>
'Display helpful tips for discovering features';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1177,4 +1147,38 @@ class SEn extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
@override
String get remoteConnection => 'Remote Access ID';
@override
String get remoteAccessId => 'Remote Access ID';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
@override
String get scanQrCode => 'Scan QR Code';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
}
+35 -32
View File
@@ -376,9 +376,6 @@ class SEs extends S {
@override
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Reproducir';
@@ -653,18 +650,6 @@ class SEs extends S {
@override
String get sortBySeriesOrder => 'Ordenar por orden de serie';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Vista de lista';
@@ -727,9 +712,6 @@ class SEs extends S {
String get chooseHomeScreenRows =>
'Elige qué filas mostrar en la pantalla de inicio';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Añadido a favoritos';
@@ -743,7 +725,7 @@ class SEs extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add';
String get addToLibrary => 'Add to library';
@override
String get unknown => 'Desconocido';
@@ -913,15 +895,9 @@ class SEs extends S {
@override
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Añadir a la cola en...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Iniciar radio de $name en...';
@@ -1016,13 +992,6 @@ class SEs extends S {
String get showHintsDescription =>
'Mostrar consejos útiles para descubrir funciones';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1194,4 +1163,38 @@ class SEs extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
@override
String get remoteConnection => 'Remote Access ID';
@override
String get remoteAccessId => 'Remote Access ID';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
@override
String get scanQrCode => 'Scan QR Code';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
}
+34 -31
View File
@@ -377,9 +377,6 @@ class SFr extends S {
@override
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Lire';
@@ -654,18 +651,6 @@ class SFr extends S {
@override
String get sortBySeriesOrder => 'Trier par ordre de série';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Vue en liste';
@@ -731,9 +716,6 @@ class SFr extends S {
String get chooseHomeScreenRows =>
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Ajoute aux favoris';
@@ -920,15 +902,9 @@ class SFr extends S {
String get addAlbumToQueueOn =>
'Ajouter l\'album a la file d\'attente sur...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Demarrer la radio $name sur...';
@@ -1024,13 +1000,6 @@ class SFr extends S {
String get showHintsDescription =>
'Afficher des conseils utiles pour découvrir les fonctionnalites';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -1203,4 +1172,38 @@ class SFr extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get localConnection => 'Server URL';
@override
String get remoteConnection => 'Remote Access ID';
@override
String get remoteAccessId => 'Remote Access ID';
@override
String get remoteAccessIdHint =>
'Find this in Music Assistant Settings → Remote Access';
@override
String get remoteAccessNote => 'For Home Assistant add-on users only';
@override
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
@override
String get remoteConnectionFailed =>
'Remote connection failed. Please check your Remote Access ID and credentials.';
@override
String get scanQrCode => 'Scan QR Code';
@override
String get pointCameraAtQr =>
'Point your camera at the Remote Access QR code in Music Assistant settings';
@override
String get cameraPermissionDenied =>
'Camera permission is required to scan QR codes';
}
+25 -1
View File
@@ -330,7 +330,31 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
builder: (context, child) {
// Wrap entire app with global player overlay
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
// Also include hidden WebView for remote WebRTC bridge
final musicProvider = context.read<MusicAssistantProvider>();
return Stack(
children: [
GlobalPlayerOverlay(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,
),
),
),
],
);
},
home: const AppStartup(),
),
+8 -32
View File
@@ -31,14 +31,20 @@ class ProviderMapping {
});
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(
itemId: json['item_id'] as String? ?? '',
providerDomain: json['provider_domain'] as String? ?? '',
providerInstance: json['provider_instance'] as String? ?? '',
available: json['available'] as bool? ?? true,
audioFormat: json['audio_format'] as Map<String, dynamic>?,
// Parse in_library field - MA uses 1/0 or true/false
inLibrary: json['in_library'] == true || json['in_library'] == 1,
inLibrary: inLibrary,
);
}
@@ -137,12 +143,6 @@ class MediaItem {
@override
int get hashCode => Object.hash(itemId, provider);
/// Check if this item is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
}
class Artist extends MediaItem {
@@ -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
Map<String, dynamic> toJson() => super.toJson();
}
@@ -298,12 +292,6 @@ class Track extends MediaItem {
String get artistsString =>
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
/// Check if this track is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
@@ -350,12 +338,6 @@ class Playlist extends MediaItem {
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 {
@@ -552,12 +534,6 @@ class Audiobook extends MediaItem {
String get narratorsString =>
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
/// Check if this audiobook is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Get progress as a percentage (0.0 to 1.0)
double get progress {
if (fullyPlayed == true) return 1.0;
+24 -41
View File
@@ -17,7 +17,6 @@ class Player {
final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
final String? activeQueue; // The active_queue from MA - has value when MA is controlling playback
Player({
required this.playerId,
@@ -36,7 +35,6 @@ class Player {
this.activeSource,
this.isExternalSource = false,
this.appId,
this.activeQueue,
});
/// Create a copy of this Player with some fields replaced
@@ -57,7 +55,6 @@ class Player {
String? activeSource,
bool? isExternalSource,
String? appId,
String? activeQueue,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -76,7 +73,6 @@ class Player {
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId,
activeQueue: activeQueue ?? this.activeQueue,
);
}
@@ -222,61 +218,50 @@ class Player {
// Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?;
// Parse active_queue - this is the key indicator for MA-controlled playback
// When MA is playing: active_queue = 'uuid:...' or similar queue ID
// When external source: active_queue = null
final activeQueue = json['active_queue'] as String?;
// Parse app_id - helps distinguish external sources on DLNA players
final appId = json['app_id'] as String?;
// Detect external source (optical, Spotify Connect, AirPlay, etc.)
//
// Detection logic priority:
// 1. If app_id == 'music_assistant' -> MA is playing, NOT external
// 2. If active_queue has a value -> MA has a queue, NOT external
// (This handles Spotify as music provider where URI is spotify:// but MA controls playback)
// 3. If app_id is set and NOT 'music_assistant' -> external source (e.g., 'http', 'spotify')
// 4. Fallback: Check URI patterns for simple external IDs (optical, line_in, etc.)
// Detect external source (optical, Spotify, AirPlay, etc.)
// Primary indicator: app_id - if not 'music_assistant', it's an external source
// Secondary indicators: URI patterns and media_type
bool isExternalSource = false;
// If MA explicitly says it's playing, trust that
if (appId == 'music_assistant') {
isExternalSource = false;
}
// If there's an active queue, MA is controlling playback (even with spotify:// URIs)
else if (activeQueue != null && activeQueue.isNotEmpty) {
isExternalSource = false;
}
// If app_id is set to something else (http, spotify, etc.), it's external
else if (appId != null && appId.isNotEmpty) {
// Check app_id first - this is the most reliable indicator for DLNA players
// When MA is playing: app_id = 'music_assistant'
// When external source: app_id = 'http', 'spotify', etc.
final appId = json['app_id'] as String?;
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
// app_id is set but not 'music_assistant' - external source is active
isExternalSource = true;
}
// Fallback: check current_media for simple external source indicators
// Only check these when we have no app_id and no active_queue
else if (json.containsKey('current_media')) {
// Also check current_media for additional external source indicators
if (!isExternalSource && json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?;
// Only check for simple external source identifiers (physical inputs)
// Do NOT flag spotify://, airplay://, etc. as external here -
// those should only be external if app_id indicates external control
// External source indicators:
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
if (uri != null) {
final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /)
// These are physical inputs that are always external
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId;
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 media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true;
@@ -302,7 +287,6 @@ class Player {
activeSource: activeSource,
isExternalSource: isExternalSource,
appId: appId,
activeQueue: activeQueue,
);
}
@@ -324,7 +308,6 @@ class Player {
if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId,
if (activeQueue != null) 'active_queue': activeQueue,
};
}
}
+416 -182
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/widgets.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
@@ -24,9 +25,9 @@ import '../services/position_tracker.dart';
import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.dart';
import '../services/remote/remote_bridge.dart';
import '../constants/timings.dart';
import '../services/database_service.dart';
import '../services/library_status_service.dart';
import '../main.dart' show audioHandler;
/// Main provider that coordinates connection, player, and library state.
@@ -65,6 +66,8 @@ class MusicAssistantProvider with ChangeNotifier {
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
DateTime? _lastPlayerSwitchTime; // Debounce for auto-selection
static const _playerSwitchCooldown = Duration(seconds: 3); // Minimum time between auto-switches
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
@@ -104,6 +107,20 @@ class MusicAssistantProvider with ChangeNotifier {
SendspinService? _sendspinService;
bool _sendspinConnected = false;
// Remote mode flag - persisted across reconnections
bool _isRemoteMode = false;
// Remote cloud URL for Sendspin connection (e.g., xxxxxxxx.ui.nabu.casa)
String? _remoteCloudUrl;
// Remote bridge for WebRTC tunnel (kept alive for reconnection)
// Created early so WebView can be added to widget tree before start()
final RemoteBridge _remoteBridge = RemoteBridge();
StreamSubscription<RemoteBridgeState>? _remoteBridgeStateSubscription;
// Remote mode auto-reconnect timer (retries every 30s when disconnected)
Timer? _remoteReconnectTimer;
// PCM audio player for raw Sendspin audio streaming
PcmAudioPlayer? _pcmAudioPlayer;
@@ -144,6 +161,10 @@ class MusicAssistantProvider with ChangeNotifier {
bool get isConnected => _connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated;
/// Get the hidden WebView widget for remote bridge WebRTC.
/// This MUST be added to the widget tree for remote connections to work.
Widget get remoteBridgeWebView => _remoteBridge.buildWebView();
// Library getters with provider filtering applied
List<Artist> get artists => filterByProvider(_artists);
List<Album> get albums => filterByProvider(_albums);
@@ -819,11 +840,102 @@ class MusicAssistantProvider with ChangeNotifier {
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
await _restoreAuthCredentials();
await connectToServer(_serverUrl!);
// Check if this is a localhost bridge URL that needs remote reconnection
final remoteAccessId = await SettingsService.getRemoteAccessId();
final isLocalhostUrl = _serverUrl!.contains('localhost') || _serverUrl!.contains('127.0.0.1');
if (isLocalhostUrl && remoteAccessId != null && remoteAccessId.isNotEmpty) {
// This is a remote connection - need to re-establish bridge
_logger.log('🌐 Detected remote mode, re-establishing bridge for: $remoteAccessId');
await _reconnectViaRemoteBridge(remoteAccessId);
} else {
// Direct/local connection
await connectToServer(_serverUrl!);
}
await _initializeLocalPlayback();
}
}
/// Re-establish remote bridge connection on app restart
Future<void> _reconnectViaRemoteBridge(String remoteAccessId) async {
final username = await SettingsService.getUsername();
final password = await SettingsService.getPassword();
if (username == null || password == null) {
_logger.log('❌ Remote reconnect failed: no credentials saved');
// Clear remote access ID since we can't reconnect
await SettingsService.clearRemoteAccessId();
return;
}
_logger.log('🔄 Starting remote bridge for reconnection...');
try {
// Use existing bridge instance (WebView is already in widget tree)
// Subscribe to bridge state changes for auto-reconnect
_remoteBridgeStateSubscription?.cancel();
_remoteBridgeStateSubscription = _remoteBridge.stateStream.listen((state) {
_logger.log('🔄 RemoteBridge state: $state (API state: $_connectionState)');
if (state == RemoteBridgeState.connected &&
(_connectionState == MAConnectionState.disconnected ||
_connectionState == MAConnectionState.error)) {
// Bridge reconnected but API is disconnected - reconnect API immediately
_logger.log('🔄 Bridge reconnected, triggering immediate API reconnect');
_reconnectApiToBridge();
}
});
final port = await _remoteBridge.start(remoteAccessId, username, password);
if (port == null) {
_logger.log('❌ Remote bridge failed to start');
// Clear remote access ID since bridge failed
await SettingsService.clearRemoteAccessId();
return;
}
_logger.log('✅ Remote bridge started on port $port');
// Update server URL to the new bridge port
final bridgeUrl = 'ws://localhost:$port';
await SettingsService.setServerUrl(bridgeUrl);
// Connect through the bridge
await connectToServer(bridgeUrl, isRemoteMode: true, remoteCloudUrl: remoteAccessId);
} catch (e) {
_logger.log('❌ Remote reconnect error: $e');
await SettingsService.clearRemoteAccessId();
}
}
/// Start the remote bridge for initial connection (called from LoginScreen).
/// Returns the local port number to connect to, or null if failed.
Future<int?> startRemoteBridge(String remoteId, String username, String password) async {
_logger.log('🔄 Starting remote bridge for: $remoteId');
try {
// Subscribe to bridge state changes for auto-reconnect
_remoteBridgeStateSubscription?.cancel();
_remoteBridgeStateSubscription = _remoteBridge.stateStream.listen((state) {
_logger.log('🔄 RemoteBridge state: $state (API state: $_connectionState)');
if (state == RemoteBridgeState.connected &&
(_connectionState == MAConnectionState.disconnected ||
_connectionState == MAConnectionState.error)) {
_logger.log('🔄 Bridge reconnected, triggering immediate API reconnect');
_reconnectApiToBridge();
}
});
final port = await _remoteBridge.start(remoteId, username, password);
return port;
} catch (e) {
_logger.log('❌ Remote bridge start error: $e');
return null;
}
}
/// Load cached players from database for instant UI display
Future<void> _loadPlayersFromDatabase() async {
try {
@@ -921,7 +1033,6 @@ class MusicAssistantProvider with ChangeNotifier {
_artists = syncService.cachedArtists;
// Note: tracks are loaded separately via API, not cached in SyncService
_logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
}
} catch (e) {
@@ -1017,11 +1128,11 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<void> _restoreAuthCredentials() async {
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Restoring saved MA token...');
_authManager.setToken(storedToken);
_logger.log('🔐 Auth token restored');
final savedCredentials = await SettingsService.getAuthCredentials();
if (savedCredentials != null) {
_logger.log('🔐 Restoring saved auth credentials...');
_authManager.deserializeCredentials(savedCredentials);
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
}
}
@@ -1029,16 +1140,19 @@ class MusicAssistantProvider with ChangeNotifier {
// CONNECTION
// ============================================================================
Future<void> connectToServer(String serverUrl) async {
Future<void> connectToServer(String serverUrl, {bool isRemoteMode = false, String? remoteCloudUrl}) async {
try {
_error = null;
_serverUrl = serverUrl;
_isRemoteMode = isRemoteMode; // Store for reconnection
_remoteCloudUrl = remoteCloudUrl; // Store cloud URL for Sendspin in remote mode
await SettingsService.setServerUrl(serverUrl);
// Dispose the old API to stop any pending reconnects
_api?.dispose();
_api = MusicAssistantAPI(serverUrl, _authManager);
_api!.isRemoteMode = isRemoteMode;
_connectionStateSubscription?.cancel();
_connectionStateSubscription = _api!.connectionState.listen(
@@ -1063,9 +1177,11 @@ class MusicAssistantProvider with ChangeNotifier {
}
// No auth required, initialize immediately
_stopRemoteReconnectTimer(); // Stop retry timer on successful connection
await _initializeAfterConnection();
} else if (state == MAConnectionState.authenticated) {
_logger.log('✅ MA authentication successful');
_stopRemoteReconnectTimer(); // Stop retry timer on successful connection
// Now safe to initialize since we're authenticated
await _initializeAfterConnection();
} else if (state == MAConnectionState.disconnected) {
@@ -1073,11 +1189,14 @@ class MusicAssistantProvider with ChangeNotifier {
// Keep showing cached data for instant UI display on reconnect
// Player list and state will be refreshed when connection is restored
_logger.log('📡 Disconnected - keeping cached players and data for instant resume');
// Start reconnect timer in remote mode
_startRemoteReconnectTimer();
}
},
onError: (error) {
_logger.log('Connection state stream error: $error');
_connectionState = MAConnectionState.error;
_startRemoteReconnectTimer(); // Start retry timer in remote mode
notifyListeners();
},
);
@@ -1623,41 +1742,6 @@ class MusicAssistantProvider with ChangeNotifier {
// LIBRARY MANAGEMENT
// ============================================================================
/// Sync library status to centralized service for reactive UI updates
void _syncLibraryStatusToService() {
final service = LibraryStatusService.instance;
// Sync albums
service.syncFromLibrary(
mediaType: 'album',
items: _albums,
getInLibrary: (a) => (a as Album).inLibrary,
getFavorite: (a) => (a as Album).favorite ?? false,
getProvider: (a) => (a as Album).provider,
getItemId: (a) => (a as Album).itemId,
);
// Sync artists
service.syncFromLibrary(
mediaType: 'artist',
items: _artists,
getInLibrary: (a) => (a as Artist).inLibrary,
getFavorite: (a) => (a as Artist).favorite ?? false,
getProvider: (a) => (a as Artist).provider,
getItemId: (a) => (a as Artist).itemId,
);
// Sync tracks
service.syncFromLibrary(
mediaType: 'track',
items: _tracks,
getInLibrary: (t) => (t as Track).inLibrary,
getFavorite: (t) => (t as Track).favorite ?? false,
getProvider: (t) => (t as Track).provider,
getItemId: (t) => (t as Track).itemId,
);
}
/// Add item to library
/// Returns true if action was executed successfully
Future<bool> addToLibrary({
@@ -1703,11 +1787,8 @@ class MusicAssistantProvider with ChangeNotifier {
// OPTIMISTIC UPDATE: Update local cache immediately for instant UI feedback
// This ensures library screens show the change even before API completes
_removeFromLocalLibrary(mediaType, libraryItemId);
// Also remove from SyncService's in-memory cache
SyncService.instance.removeFromCacheByLibraryId(mediaType, libraryItemId);
// Also mark as deleted in database cache so it doesn't reappear on next load
// Use the library ID aware method that handles cases where itemId differs from libraryId
DatabaseService.instance.markCachedItemDeletedByLibraryId(mediaType, libraryItemId.toString());
DatabaseService.instance.markCachedItemDeleted(mediaType, libraryItemId.toString());
// Invalidate caches that could show stale inLibrary status
_cacheService.invalidateSearchCache();
if (mediaType == 'album') {
@@ -1828,7 +1909,6 @@ class MusicAssistantProvider with ChangeNotifier {
} else if (mediaType == 'podcast') {
_podcasts = await _api!.getPodcasts(limit: 100);
}
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
_logger.log('⚠️ Background library refresh failed: $e');
@@ -1914,13 +1994,28 @@ class MusicAssistantProvider with ChangeNotifier {
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
try {
await connectToServer(_serverUrl!);
_logger.log('🔄 Reconnection successful');
} catch (e) {
_logger.log('🔄 Reconnection failed: $e');
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl (remote: $_isRemoteMode)');
// In remote mode, retry multiple times since bridge may be recovering
final maxRetries = _isRemoteMode ? 5 : 1;
for (var attempt = 1; attempt <= maxRetries; attempt++) {
try {
await connectToServer(_serverUrl!, isRemoteMode: _isRemoteMode, remoteCloudUrl: _remoteCloudUrl);
_logger.log('🔄 Reconnection successful on attempt $attempt');
return;
} catch (e) {
_logger.log('🔄 Reconnection attempt $attempt/$maxRetries failed: $e');
if (attempt < maxRetries) {
// Exponential backoff: 2s, 4s, 8s, 16s
final delay = Duration(seconds: 2 << (attempt - 1));
_logger.log('🔄 Retrying in ${delay.inSeconds}s...');
await Future.delayed(delay);
}
}
}
_logger.log('🔄 All reconnection attempts failed');
// Start periodic reconnect timer for remote mode
_startRemoteReconnectTimer();
} else {
_logger.log('🔄 Already connected, verifying connection...');
try {
@@ -1931,7 +2026,7 @@ class MusicAssistantProvider with ChangeNotifier {
} catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e');
try {
await connectToServer(_serverUrl!);
await connectToServer(_serverUrl!, isRemoteMode: _isRemoteMode, remoteCloudUrl: _remoteCloudUrl);
} catch (reconnectError) {
_logger.log('🔄 Reconnection failed: $reconnectError');
}
@@ -1939,6 +2034,59 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Start periodic reconnect timer for remote mode (every 30s)
void _startRemoteReconnectTimer() {
if (!_isRemoteMode) return;
if (_remoteReconnectTimer != null) return; // Already running
_logger.log('🔄 Starting remote reconnect timer (30s interval)');
_remoteReconnectTimer = Timer.periodic(const Duration(seconds: 30), (_) async {
if (_connectionState == MAConnectionState.connected ||
_connectionState == MAConnectionState.authenticated) {
_logger.log('🔄 Remote reconnect timer: Already connected, stopping timer');
_stopRemoteReconnectTimer();
return;
}
_logger.log('🔄 Remote reconnect timer: Attempting reconnection...');
try {
await connectToServer(_serverUrl!, isRemoteMode: _isRemoteMode, remoteCloudUrl: _remoteCloudUrl);
_logger.log('🔄 Remote reconnect timer: Reconnection successful');
_stopRemoteReconnectTimer();
} catch (e) {
_logger.log('🔄 Remote reconnect timer: Reconnection failed: $e');
// Timer will fire again in 30s
}
});
}
/// Stop the remote reconnect timer
void _stopRemoteReconnectTimer() {
if (_remoteReconnectTimer != null) {
_logger.log('🔄 Stopping remote reconnect timer');
_remoteReconnectTimer!.cancel();
_remoteReconnectTimer = null;
}
}
/// Reconnect the API to the existing remote bridge
/// Called when RemoteBridge reconnects and API is disconnected
Future<void> _reconnectApiToBridge() async {
if (_serverUrl == null) {
_logger.log('⚠️ No server URL for API reconnect');
return;
}
try {
_logger.log('🔄 Reconnecting API to bridge at $_serverUrl');
await connectToServer(_serverUrl!, isRemoteMode: _isRemoteMode, remoteCloudUrl: _remoteCloudUrl);
_logger.log('✅ API reconnected to bridge successfully');
} catch (e) {
_logger.log('❌ API reconnect to bridge failed: $e');
// The 30s timer will try again
}
}
// ============================================================================
// LOCAL PLAYER
// ============================================================================
@@ -2172,17 +2320,71 @@ class MusicAssistantProvider with ChangeNotifier {
/// - MA 2.7.x: NO proxy, must either:
/// - Use local IP with port 8927 exposed, OR
/// - Manually configure reverse proxy to route /sendspin to port 8927
/// - Remote mode: Uses WebRTC bridge tunnel (ws://localhost:PORT/sendspin)
Future<bool> _connectViaSendspin() async {
if (_api == null || _serverUrl == null) return false;
try {
final serverVersion = _getServerVersionString();
final hasProxy = _serverHasSendspinProxy();
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy');
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy, remote: $_isRemoteMode');
// Initialize Sendspin service
// In remote mode, connect via the WebRTC bridge tunnel
if (_isRemoteMode) {
// The bridge provides Sendspin tunnel at ws://localhost:PORT/sendspin
// Extract port from the server URL (which is ws://localhost:PORT for remote)
final serverUri = Uri.parse(_serverUrl!);
if (serverUri.host != 'localhost' && serverUri.host != '127.0.0.1') {
_logger.log('⚠️ Sendspin: Remote mode but server URL is not localhost bridge');
return false;
}
final bridgeSendspinUrl = 'ws://${serverUri.host}:${serverUri.port}/sendspin';
_logger.log('Sendspin: Remote mode - connecting via WebRTC bridge: $bridgeSendspinUrl');
// Initialize Sendspin service with the bridge's /sendspin endpoint
_sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!); // Base URL for service reference
// Initialize PCM audio player
_pcmAudioPlayer?.dispose();
_pcmAudioPlayer = PcmAudioPlayer();
final pcmInitialized = await _pcmAudioPlayer!.initialize();
if (pcmInitialized) {
_logger.log('✅ PCM audio player initialized for Sendspin');
await _pcmAudioPlayer!.connectToStream(_sendspinService!.audioDataStream);
}
// Wire up callbacks
_sendspinService!.onPlay = _handleSendspinPlay;
_sendspinService!.onPause = _handleSendspinPause;
_sendspinService!.onStop = _handleSendspinStop;
_sendspinService!.onSeek = _handleSendspinSeek;
_sendspinService!.onVolume = _handleSendspinVolume;
_sendspinService!.onStreamStart = _handleSendspinStreamStart;
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('Sendspin: Player ID: $playerId');
// Connect directly to the bridge's /sendspin endpoint
// No proxy auth needed - the bridge handles authentication via WebRTC
final connected = await _sendspinService!.connectWithUrl(bridgeSendspinUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via WebRTC bridge tunnel');
return true;
}
_logger.log('⚠️ Sendspin: WebRTC bridge tunnel connection failed');
return false;
}
// Local mode - use server URL directly
String sendspinServerUrl = _serverUrl!;
// Initialize Sendspin service with the appropriate server URL
_sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!);
_sendspinService = SendspinService(sendspinServerUrl);
// Set auth token for proxy authentication (MA 2.7.1+ or manually configured proxy)
final authToken = await SettingsService.getMaAuthToken();
@@ -2215,13 +2417,13 @@ class MusicAssistantProvider with ChangeNotifier {
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('Sendspin: Player ID: $playerId');
// Parse server URL to determine connection strategy
final serverUri = Uri.parse(_serverUrl!.startsWith('http')
? _serverUrl!
: 'https://$_serverUrl');
// Parse server URL to determine connection strategy (local mode only - remote handled above)
final serverUri = Uri.parse(sendspinServerUrl.startsWith('http')
? sendspinServerUrl
: 'https://$sendspinServerUrl');
final isLocalIp = _isLocalNetworkHost(serverUri.host);
final isHttps = serverUri.scheme == 'https' ||
(!_serverUrl!.contains('://') && !isLocalIp);
(!sendspinServerUrl.contains('://') && !isLocalIp);
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
if (isLocalIp) {
@@ -2736,31 +2938,21 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('🎙️ Podcast currentMedia: $currentMedia');
}
// Check for external source (optical, Spotify Connect, etc.) - skip caching stale metadata
// Use same logic as Player.fromJson:
// 1. If app_id == 'music_assistant' -> NOT external
// 2. If active_queue has value -> NOT external (MA controls playback)
// 3. If app_id is set and != 'music_assistant' -> external
// 4. Fallback: check for simple external IDs (optical, etc.)
final appId = event['app_id'] as String?;
final activeQueue = event['active_queue'] as String?;
// Check for external source (optical, Spotify, etc.) - skip caching stale metadata
bool isExternalSource = false;
if (appId == 'music_assistant') {
isExternalSource = false;
} else if (activeQueue != null && activeQueue.isNotEmpty) {
isExternalSource = false;
} else if (appId != null && appId.isNotEmpty) {
isExternalSource = true;
} else if (uri != null) {
if (uri != null) {
final uriLower = uri.toLowerCase();
// Only check for simple external source identifiers (physical inputs)
// Do NOT flag spotify://, etc. - those need app_id/active_queue context
// Simple external source identifiers (no :// or /)
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
isExternalSource = isSimpleExternalId;
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
// Also treat unknown media type with non-MA URIs as external
if (!isExternalSource && mediaType == 'unknown' &&
@@ -2770,7 +2962,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
if (isExternalSource) {
_logger.log('📡 External source detected for $playerName (app_id: $appId, uri: $uri) - skipping metadata cache');
_logger.log('📡 External source detected for $playerName (uri: $uri) - skipping metadata cache');
// Clear cached track for this player to avoid showing stale data
_cacheService.clearCachedTrackForPlayer(playerId);
return; // Skip further processing for external sources
@@ -2796,7 +2988,14 @@ class MusicAssistantProvider with ChangeNotifier {
Map<String, dynamic>? metadata;
if (imageUrl != null) {
var finalImageUrl = imageUrl;
if (_serverUrl != null) {
// In remote mode, don't use imageproxy (can't reach MA server through WebRTC tunnel)
// Use the original URL directly if it's an external HTTPS URL
if (_api?.isRemoteMode == true) {
// Keep external URLs as-is, they should work directly
if (!imageUrl.startsWith('https://')) {
finalImageUrl = imageUrl; // May not load, but better than broken imageproxy
}
} else if (_serverUrl != null && !_serverUrl!.startsWith('ws://') && !_serverUrl!.startsWith('wss://')) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
@@ -3036,7 +3235,9 @@ class MusicAssistantProvider with ChangeNotifier {
}
artist ??= 'Unknown Artist';
if (imageUrl != null && _serverUrl != null) {
// In remote mode, don't use imageproxy (can't reach MA server through WebRTC tunnel)
if (imageUrl != null && _api?.isRemoteMode != true && _serverUrl != null &&
!_serverUrl!.startsWith('ws://') && !_serverUrl!.startsWith('wss://')) {
try {
final imgUri = Uri.parse(imageUrl);
final queryString = imgUri.query;
@@ -3214,7 +3415,6 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('⚠️ Failed to refresh tracks: $e');
}
_syncLibraryStatusToService();
notifyListeners();
_logger.log('✅ Force sync complete');
}
@@ -3833,20 +4033,33 @@ class MusicAssistantProvider with ChangeNotifier {
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId, coldStart=$coldStart');
// Log settings for debugging player selection issues
_logger.log('⚙️ Player selection settings: preferLocal=$preferLocalPlayer, disableAutoSwitch=$disableAutoSwitch, coldStart=$coldStart');
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId');
// SIMPLIFIED PLAYER SELECTION LOGIC:
// 1. User's manual selection is always respected
// 2. On app reopen: restore last selection
// 3. If selected player unavailable: switch to local player
// 4. First launch (no history): select local player
// 5. Local player unavailable: select first available
// If "Prefer Local Player" is ON, always try to select local player first
// This takes priority even over the currently selected player
if (preferLocalPlayer && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
} catch (e) {
// Local player not available yet, will fall through to other options
_logger.log('⚠️ Prefer local player ON but local player not available');
}
}
// Keep currently selected player if still available
if (_selectedPlayer != null && !coldStart) {
// Keep currently selected player if still available (and not overridden by prefer local)
// On coldStart, skip this block and apply full priority logic
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
// Check if selected player is still available - also check translated Cast/Sendspin IDs
// When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection
final selectedId = _selectedPlayer!.playerId;
final translatedId = _castToSendspinIdMap[selectedId];
String? reverseTranslatedId;
@@ -3863,53 +4076,101 @@ class MusicAssistantProvider with ChangeNotifier {
(reverseTranslatedId != null && p.playerId == reverseTranslatedId)),
);
if (stillAvailable) {
// Find the actual player in the list (might be Cast or Sendspin version)
final currentPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == selectedId ||
(translatedId != null && p.playerId == translatedId) ||
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
);
// Keep current selection - no auto-switching during non-coldStart refreshes
// Auto-switch to playing player is now ONLY done on coldStart (see below)
playerToSelect = currentPlayer;
if (currentPlayer.playerId != selectedId) {
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
} else {
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
}
} else {
// Selected player went offline - will fall through to local player
_logger.log('⚠️ Selected player ${_selectedPlayer!.name} no longer available');
}
}
// Priority 1: Restore last selected player (on coldStart)
if (playerToSelect == null && coldStart && lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('🔄 Restored last selected player: ${playerToSelect.name}');
}
if (coldStart) {
_logger.log('🚀 Cold start: applying full priority logic');
}
// Priority 2: Local player (default fallback)
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Selected local player: ${playerToSelect.name}');
} catch (e) {
_logger.log('⚠️ Local player not available');
}
}
// Priority 3: First available player
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
// Smart auto-selection priority (coldStart only for auto-switch to playing):
// Priority 1: Single playing player (only on coldStart and if disableAutoSwitch is OFF)
// Priority 2: Local player (if preferLocalPlayer is OFF but we still try local as fallback)
// Priority 3: Last manually selected player
// Priority 4: First available player
// Priority 1: Auto-switch to single playing player
// ONLY on coldStart AND only if disableAutoSwitch is OFF
// Also apply cooldown to prevent rapid successive auto-switches
final cooldownActive = _lastPlayerSwitchTime != null &&
DateTime.now().difference(_lastPlayerSwitchTime!) < _playerSwitchCooldown;
if (coldStart && !disableAutoSwitch && !preferLocalPlayer && !cooldownActive) {
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
).toList();
if (playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
_lastPlayerSwitchTime = DateTime.now(); // Update cooldown timestamp
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
} else if (playingPlayers.length > 1) {
_logger.log('⚠️ Multiple players playing (${playingPlayers.length}), skipping auto-switch');
}
} else if (cooldownActive) {
_logger.log('⏳ Auto-switch cooldown active, skipping');
} else if (!coldStart) {
_logger.log('⚠️ Not coldStart, skipping auto-switch to playing player');
} else if (disableAutoSwitch) {
_logger.log('⚠️ Auto-switch disabled by user setting');
}
// Priority 2: Local player (fallback even when preferLocalPlayer is OFF)
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {
// Local player not found or not available - check why
final localPlayer = _availablePlayers
.where((p) => p.playerId == builtinPlayerId)
.toList();
if (localPlayer.isEmpty) {
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
} else {
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
}
}
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
}
// Priority 3: Last manually selected player
if (playerToSelect == null && lastSelectedPlayerId != null) {
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
(p) => p!.playerId == lastSelectedPlayerId && p.available,
orElse: () => null,
);
if (playerToSelect != null) {
_logger.log('🔄 Auto-selected last used player: ${playerToSelect?.name}');
}
}
// Priority 4: First available player
if (playerToSelect == null) {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.available,
orElse: () => _availablePlayers.first,
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
}
selectPlayer(playerToSelect);
@@ -4251,16 +4512,16 @@ class MusicAssistantProvider with ChangeNotifier {
if (_api == null) return;
try {
// Log app_id and active_queue for playing players to help diagnose external source detection
// Log app_id for playing players to help diagnose external source detection
if (player.state == 'playing') {
_logger.log('🔍 Preload ${player.name}: state=${player.state}, app_id=${player.appId}, active_queue=${player.activeQueue != null ? "set" : "null"}, isExternalSource=${player.isExternalSource}');
_logger.log('🔍 Preload ${player.name}: state=${player.state}, app_id=${player.appId}, isExternalSource=${player.isExternalSource}');
} else {
_logger.log('🔍 Preload ${player.name}: state=${player.state}, available=${player.available}');
}
// Skip external sources - they're not playing MA content
if (player.isExternalSource) {
_logger.log('🔍 Preload ${player.name}: SKIPPED - external source (app_id=${player.appId}, active_queue=${player.activeQueue != null ? "set" : "null"})');
_logger.log('🔍 Preload ${player.name}: SKIPPED - external source (app_id=${player.appId})');
_cacheService.setCachedTrackForPlayer(player.playerId, null);
return;
}
@@ -4471,7 +4732,6 @@ class MusicAssistantProvider with ChangeNotifier {
currentItemId: rawPlayer.currentItemId,
isExternalSource: rawPlayer.isExternalSource,
appId: rawPlayer.appId,
activeQueue: rawPlayer.activeQueue,
);
_selectedPlayer = updatedPlayer;
@@ -4694,32 +4954,19 @@ class MusicAssistantProvider with ChangeNotifier {
);
}
} else {
// No queue data available from API
// No track data available - show player name placeholder for active players
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
// For builtin player, try to preserve cached track data since queue API doesn't return it
// The cached track was set in selectPlayer() and contains valid data
if (isBuiltinPlayer && _currentTrack == null) {
final cachedTrack = getCachedTrackForPlayer(_selectedPlayer!.playerId);
if (cachedTrack != null) {
_currentTrack = cachedTrack;
stateChanged = true;
_logger.log('🎵 Restored cached track for builtin player: ${cachedTrack.name}');
}
}
// Only clear track if we have no cached data to fall back on
// Don't clear for builtin player - it maintains its own playback state
if (_currentTrack != null && !isBuiltinPlayer) {
if (_currentTrack != null) {
_currentTrack = null;
stateChanged = true;
// Persist cleared track state
_persistPlaybackState();
}
// Show player name placeholder only if we have no track data
if (_currentTrack == null && (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused')) {
// Show player name placeholder so notification shows correct player
if (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused') {
final mediaItem = audio_service.MediaItem(
id: 'player_${_selectedPlayer!.playerId}',
title: _selectedPlayer!.name,
@@ -4741,22 +4988,7 @@ class MusicAssistantProvider with ChangeNotifier {
duration: Duration.zero,
);
}
} else if (_currentTrack != null && isBuiltinPlayer) {
// Builtin player has track data - update notification with it
final track = _currentTrack!;
final mediaItem = audio_service.MediaItem(
id: track.uri ?? track.itemId,
title: track.name,
artist: track.artistsString,
album: track.album?.name,
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null,
duration: track.duration,
);
audioHandler.updateLocalModeNotification(
item: mediaItem,
playing: _selectedPlayer!.state == 'playing',
);
} else if (_selectedPlayer!.state != 'playing' && _selectedPlayer!.state != 'paused') {
} else {
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
_positionTracker.clear();
@@ -4850,12 +5082,12 @@ class MusicAssistantProvider with ChangeNotifier {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
_syncLibraryStatusToService();
notifyListeners();
}
// Fetch tracks from API (not cached - too many items)
if (_api != null) {
// Skip in remote mode to keep connection responsive
if (_api != null && !_api!.isRemoteMode) {
try {
_tracks = await _api!.getTracks(
limit: LibraryConstants.maxLibraryItems,
@@ -4865,10 +5097,11 @@ class MusicAssistantProvider with ChangeNotifier {
} catch (e) {
_logger.log('⚠️ Failed to fetch tracks: $e');
}
} else if (_api?.isRemoteMode == true) {
_logger.log('🌐 Remote mode: skipping tracks fetch');
}
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
// Trigger background sync (non-blocking)
@@ -4887,6 +5120,13 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> _syncLibraryInBackground() async {
if (_api == null) return;
// In remote mode, delay sync to let player/queue operations complete first
if (_api!.isRemoteMode) {
_logger.log('🌐 Remote mode: delaying library sync by 3 seconds');
await Future.delayed(const Duration(seconds: 3));
if (_api == null || !isConnected) return; // Check still connected
}
final syncService = SyncService.instance;
// Listen for sync completion to update our lists
@@ -4895,14 +5135,20 @@ class MusicAssistantProvider with ChangeNotifier {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
_syncLibraryStatusToService();
notifyListeners();
}
syncService.removeListener(onSyncComplete);
}
syncService.addListener(onSyncComplete);
await syncService.syncFromApi(_api!, providerInstanceIds: providerIdsForApiCalls);
// In remote mode, force sync but use gentler approach (handled in SyncService)
if (_api!.isRemoteMode) {
_logger.log('🌐 Remote mode: starting gentle library sync');
await syncService.forceSync(_api!, providerInstanceIds: providerIdsForApiCalls);
} else {
await syncService.syncFromApi(_api!, providerInstanceIds: providerIdsForApiCalls);
}
}
/// Load radio stations from the library
@@ -4993,7 +5239,6 @@ class MusicAssistantProvider with ChangeNotifier {
SyncService.instance.updateCachedArtists(_artists);
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
@@ -5029,7 +5274,6 @@ class MusicAssistantProvider with ChangeNotifier {
SyncService.instance.updateCachedAlbums(_albums);
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
@@ -5255,18 +5499,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<void> addTracksToQueue(String playerId, List<Track> tracks, {int startIndex = 0}) async {
try {
await _api?.addTracksToQueue(playerId, tracks, startIndex: startIndex);
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Add tracks to queue');
_error = errorInfo.userMessage;
ErrorHandler.logError('Add tracks to queue', e);
notifyListeners();
rethrow;
}
}
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
try {
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
@@ -5987,6 +6219,8 @@ class MusicAssistantProvider with ChangeNotifier {
_sleepTimer?.cancel();
_sleepTimerDisplayTimer?.cancel();
_idleServiceTimer?.cancel();
_remoteReconnectTimer?.cancel();
_remoteBridgeStateSubscription?.cancel();
_connectionStateSubscription?.cancel();
_localPlayerEventSubscription?.cancel();
_playerUpdatedEventSubscription?.cancel();
+360 -384
View File
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart';
@@ -11,12 +9,8 @@ import '../theme/theme_provider.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../services/library_status_service.dart';
import '../widgets/global_player_overlay.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 '../theme/design_tokens.dart';
import 'artist_details_screen.dart';
@@ -38,12 +32,15 @@ class AlbumDetailsScreen extends StatefulWidget {
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
}
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin, LibraryStatusMixin {
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin {
final _logger = DebugLogger();
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
bool _isDescriptionExpanded = false;
String? _albumDescription;
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}' : '';
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'album',
widget.album.provider,
widget.album.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
_isFavorite = widget.album.favorite ?? false;
_isInLibrary = widget.album.inLibrary;
_loadTracks();
_loadAlbumDescription();
// 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 fresh data loading until after hero animation completes
// This prevents setState with new image URL during animation → grey icon flash
// CRITICAL FIX: Defer both fresh data loading AND color extraction until
// after the hero animation completes. This prevents:
// 1. setState with new image URL during animation → grey icon flash
// 2. Expensive palette extraction blocking animation frames
WidgetsBinding.instance.addPostFrameCallback((_) {
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadFreshAlbumData();
_extractColors();
}
});
});
@@ -111,14 +90,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (freshAlbum != null && mounted) {
setState(() {
_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
_extractColors();
}
@@ -134,18 +107,15 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (imageUrl == null) return;
try {
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for album: $e');
@@ -154,13 +124,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
final newState = !_isFavorite;
bool success;
if (newState) {
@@ -206,7 +172,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal');
rollbackFavoriteOperation();
throw Exception('Could not determine library ID for this album');
}
@@ -217,7 +182,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
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
maProvider.invalidateHomeCache();
@@ -228,17 +197,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
content: Text(
isOffline
? 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),
),
);
}
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
_logger.log('Error toggling favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -254,10 +220,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
/// Toggle library status
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
final newState = !_isInLibrary;
if (newState) {
// Add to library - MUST use non-library provider
String? actualProvider;
@@ -287,8 +253,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
// OPTIMISTIC UPDATE via centralized service
setLibraryStatus(newState);
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
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');
final success = await maProvider.addToLibrary(
// Fire and forget - API call happens in background
maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
).catchError((e) {
_logger.log('❌ Failed to add album to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
} else {
// Remove from library
int? libraryItemId;
@@ -331,8 +302,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
return;
}
// OPTIMISTIC UPDATE via centralized service
setLibraryStatus(newState);
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
if (mounted) {
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',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
).catchError((e) {
_logger.log('❌ Failed to remove album from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
}
});
}
} catch (e) {
rollbackLibraryOperation();
_logger.log('Error toggling album library: $e');
if (mounted) {
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 {
final provider = context.read<MusicAssistantProvider>();
final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
@@ -650,60 +542,161 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _addAlbumToQueue() {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addAlbumToQueueOn,
onPlayerSelected: (player) async {
try {
_logger.log('Adding album to queue on ${player.name}');
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.albumAddedToQueue),
duration: const Duration(seconds: 1),
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
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 {
_logger.log('Adding album to queue on ${player.name}');
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.albumAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding album to queue: $e');
_showError('Failed to add album to queue: $e');
}
},
);
},
),
),
);
}
} catch (e) {
_logger.log('Error adding album to queue: $e');
_showError('Failed to add album to queue: $e');
}
},
);
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Show mini player again when sheet closes
GlobalPlayerOverlay.showPlayer();
});
}
void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
// Add tracks from this index onwards to queue
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
startIndex: index,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
// Slide mini player down out of the way
GlobalPlayerOverlay.hidePlayer();
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 {
// Add tracks from this index onwards to queue
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: index,
clearQueue: false,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
},
),
),
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
void _navigateToArtist() {
@@ -1000,6 +993,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [
// Main Play Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -1019,26 +1013,37 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
const SizedBox(width: 12),
// Library Button
// "Play on..." Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Symbols.book_2,
size: 25,
fill: isInLibrary ? 1 : 0,
color: isInLibrary
? colorScheme.primary
: Colors.white70,
child: const Icon(Icons.speaker_group_outlined),
),
),
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),
),
),
@@ -1057,54 +1062,34 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
child: Icon(
Icons.favorite,
size: 25,
color: isFavorite
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite
? colorScheme.error
: Colors.white70,
: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
// Library 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.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(
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,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
],
@@ -1137,78 +1122,138 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
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(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${track.position ?? index + 1}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${track.position ?? index + 1}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
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;
});
},
),
onTap: () => _playTrack(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,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
);
},
childCount: _tracks.length,
@@ -1264,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) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
File diff suppressed because it is too large Load Diff
+56 -251
View File
@@ -1,16 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/media_context_menu.dart';
import '../theme/theme_provider.dart';
import '../services/settings_service.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
@@ -36,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
}
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@@ -120,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
Widget build(BuildContext context) {
final adaptiveTheme = context.select<ThemeProvider, bool>(
@@ -315,49 +168,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
},
color: colorScheme.onSurface,
),
actions: [
// Three-dot menu with sort/view options
Padding(
padding: const EdgeInsets.only(right: 8),
child: SizedBox(
height: 40,
width: 40,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
isFavorite: false,
isInLibrary: false,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
viewMode: _viewMode,
onCycleView: _cycleViewMode,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 20,
color: Colors.white70,
),
),
),
),
),
],
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -432,16 +242,55 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
),
),
// Audiobooks Section Header
// Audiobooks Section Header with controls
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0),
child: Text(
S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Row(
children: [
Text(
S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
),
@@ -495,31 +344,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
// Get adaptive scheme for context menu
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return InkWell(
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1.0,
@@ -610,8 +438,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
],
),
);
}
@@ -623,30 +450,9 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
// Get adaptive scheme for context menu
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
@@ -727,8 +533,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
),
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
);
}
+15 -157
View File
@@ -1,14 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -38,7 +35,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final _logger = DebugLogger();
bool _isDescriptionExpanded = false;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedChapterIndex;
@@ -50,24 +46,17 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Use full audiobook if loaded, otherwise use widget audiobook
Audiobook get _audiobook => _fullAudiobook ?? widget.audiobook;
/// Check if audiobook is in library
bool _checkIfInLibrary(Audiobook audiobook) {
if (audiobook.provider == 'library') return true;
return audiobook.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
void initState() {
super.initState();
_isFavorite = widget.audiobook.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.audiobook);
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
// Load full audiobook details with chapters
_loadAudiobookDetails();
});
@@ -118,18 +107,15 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (imageUrl == null) return;
try {
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for audiobook: $e');
@@ -223,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) {
final maProvider = context.read<MusicAssistantProvider>();
@@ -748,6 +652,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [
// Play/Resume Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -781,33 +686,26 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.replay, size: 25),
child: const Icon(Icons.replay),
),
),
],
const SizedBox(width: 12),
// Library Button
// Play On Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
onPressed: () => _showPlayOnMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: Colors.white70,
),
child: const Icon(Icons.speaker_group_outlined),
),
),
@@ -826,50 +724,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
),
child: Icon(
Icons.favorite,
size: 25,
_isFavorite ? Icons.favorite : Icons.favorite_border,
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.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,
),
: colorScheme.onSurfaceVariant,
),
),
),
+1 -2
View File
@@ -271,8 +271,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Use read since we only need the provider for non-reactive operations (getImageUrl)
final maProvider = context.read<MusicAssistantProvider>();
final maProvider = context.watch<MusicAssistantProvider>();
final l10n = S.of(context)!;
return Scaffold(
+6 -2
View File
@@ -41,10 +41,12 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
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(
SnackBar(
content: Text(S.of(context)!.logsCopied),
content: Text('${S.of(context)!.logsCopied} (${_filteredEntries.length} entries)'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
@@ -61,6 +63,8 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
report,
subject: S.of(context)!.ensembleBugReport,
);
} catch (e) {
_logger.error('Share failed', error: e);
} finally {
if (mounted) {
setState(() => _isGeneratingReport = false);
+3 -4
View File
@@ -43,6 +43,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -65,13 +66,11 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
),
centerTitle: true,
),
body: _buildPlaylistsList(context),
body: _buildPlaylistsList(context, provider),
);
}
Widget _buildPlaylistsList(BuildContext context) {
// Use read since we only need the provider for non-reactive operations
final provider = context.read<MusicAssistantProvider>();
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading
+11 -16
View File
@@ -11,6 +11,7 @@ class LibraryTracksScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -33,41 +34,35 @@ class LibraryTracksScreen extends StatelessWidget {
),
centerTitle: true,
),
body: _buildTracksList(context),
body: _buildTracksList(context, provider),
);
}
Widget _buildTracksList(BuildContext context) {
// Use select to only rebuild when tracks or isLoading changes
final (tracks, isLoading) = context.select<MusicAssistantProvider, (List, bool)>(
(p) => (p.tracks, p.isLoading),
);
Widget _buildTracksList(
BuildContext context, MusicAssistantProvider provider) {
final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while 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(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
if (tracks.isEmpty) {
return EmptyState.tracks(
context: context,
onRefresh: context.read<MusicAssistantProvider>().loadLibrary,
);
if (provider.tracks.isEmpty) {
return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
}
return ListView.builder(
itemCount: tracks.length,
itemCount: provider.tracks.length,
padding: const EdgeInsets.all(8),
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
itemBuilder: (context, index) {
final track = tracks[index] as Track;
return _buildTrackTile(context, track, index);
final track = provider.tracks[index];
return _buildTrackTile(context, track, provider, index);
},
);
}
@@ -75,9 +70,9 @@ class LibraryTracksScreen extends StatelessWidget {
Widget _buildTrackTile(
BuildContext context,
Track track,
MusicAssistantProvider maProvider,
int index,
) {
final maProvider = context.read<MusicAssistantProvider>();
final imageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 128)
: null;
File diff suppressed because it is too large Load Diff
+16
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
@@ -226,6 +227,21 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
titleSpacing: 0,
centerTitle: false,
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
ListenableBuilder(
listenable: SyncService.instance,
File diff suppressed because it is too large Load Diff
+336 -423
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
@@ -11,8 +10,6 @@ import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/hires_badge.dart';
import '../widgets/media_context_menu.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
@@ -44,9 +41,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
List<Track> _tracks = [];
bool _isLoading = true;
bool _isFavorite = false;
late bool _isInLibrary;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedTrackIndex;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -58,15 +55,15 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void initState() {
super.initState();
_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
}
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
});
}
@@ -77,18 +74,15 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
if (imageUrl == null) return;
try {
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for playlist: $e');
@@ -233,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 {
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
@@ -410,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 {
if (_tracks.isEmpty) return;
@@ -556,130 +387,158 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
);
}
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
void _addPlaylistToQueue() {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.hidePlayer();
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
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: [
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
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 {
_logger.log('Adding playlist to queue on ${player.name}');
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Playlist added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding playlist to queue: $e');
_showError('Failed to add playlist to queue: $e');
}
},
);
},
),
),
),
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)!.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),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
);
}
void _addPlaylistToQueue() {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
_logger.log('Adding playlist to queue on ${player.name}');
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
);
_logger.log('Playlist added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding playlist to queue: $e');
_showError('Failed to add playlist to queue: $e');
}
},
);
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
startIndex: index,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
GlobalPlayerOverlay.hidePlayer();
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 {
await maProvider.playTracks(
player.playerId,
_tracks,
startIndex: index,
clearQueue: false,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
},
),
),
);
}
} catch (e) {
_logger.log('Error adding to queue: $e');
_showError('Failed to add to queue: $e');
}
},
);
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
@@ -923,6 +782,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
children: [
// Main Play Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -942,6 +802,42 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
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
SizedBox(
height: 50,
@@ -955,53 +851,10 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
),
child: Icon(
Icons.favorite,
size: 25,
_isFavorite ? Icons.favorite : Icons.favorite_border,
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 (_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,
),
: colorScheme.onSurfaceVariant,
),
),
),
@@ -1035,108 +888,168 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
final trackImageUrl = context.read<MusicAssistantProvider>().getImageUrl(track, size: 80);
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
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: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Track number
SizedBox(
width: 28,
child: Text(
'${index + 1}',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: trackImageUrl != null
? CachedNetworkImage(
imageUrl: trackImageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Track number
SizedBox(
width: 40,
width: 28,
child: Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
'${index + 1}',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
),
],
const SizedBox(width: 12),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: trackImageUrl != null
? CachedNetworkImage(
imageUrl: trackImageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.music_note,
size: 24,
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
title: Text(
track.name,
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
track.artistsString,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
)
: null,
onTap: () {
if (isExpanded) {
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
),
onTap: () => _playTrack(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(),
),
],
);
},
childCount: _tracks.length,
+191 -373
View File
@@ -1,23 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../theme/design_tokens.dart';
import '../services/debug_logger.dart';
import '../services/settings_service.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import '../utils/page_transitions.dart';
import 'podcast_episode_detail_screen.dart';
class PodcastDetailScreen extends StatefulWidget {
final MediaItem podcast;
@@ -43,8 +36,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false;
bool _isInLibrary = false;
bool _isFavorite = false;
String _sortOrder = 'newest'; // 'newest', 'oldest', 'alpha', 'duration'
String? _expandedEpisodeId; // Track which episode is expanded for actions
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -52,109 +44,16 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
void initState() {
super.initState();
_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
}
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
}
});
_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
bool _checkIfInLibrary(MediaItem podcast) {
if (podcast.provider == 'library') return true;
@@ -287,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 {
final maProvider = context.read<MusicAssistantProvider>();
// Use initialImageUrl first for consistency with hero animation
@@ -359,17 +193,14 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (imageUrl != null) {
try {
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
if (mounted && colorSchemes != null) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('🎙️ Error extracting colors: $e');
@@ -430,7 +261,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
setState(() {
_episodes = episodes;
_sortEpisodes();
});
}
}
@@ -576,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
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
@@ -768,6 +569,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
children: [
// Main Play Latest Episode Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -789,6 +591,24 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
),
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
SizedBox(
height: 50,
@@ -802,76 +622,10 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
),
child: Icon(
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: Colors.white70,
),
),
),
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,
),
: colorScheme.onSurfaceVariant,
),
),
),
@@ -978,126 +732,190 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final episode = _episodes[index];
final episodeDescription = episode.metadata?['description'] as String?;
final duration = episode.duration;
final episodeId = episode.uri ?? episode.itemId;
final isExpanded = _expandedEpisodeId == episodeId;
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
// Try to get release date from metadata
final releaseDate = _formatReleaseDate(episode.metadata);
return GestureDetector(
onTap: () => _playEpisode(episode),
onLongPressStart: (details) => _showEpisodeContextMenu(
context,
details.globalPosition,
episode,
episodeImageUrl,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover
Hero(
tag: HeroTags.podcastEpisodeCover + (episode.uri ?? episode.itemId),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
return Column(
children: [
InkWell(
onTap: () => _playEpisode(episode),
onLongPress: () {
setState(() {
_expandedEpisodeId = isExpanded ? null : episodeId;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode cover (bigger than before)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
height: 72,
color: colorScheme.surfaceContainerHighest,
child: episodeImageUrl != null
? CachedNetworkImage(
imageUrl: episodeImageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.podcast,
size: 32,
color: colorScheme.onSurfaceVariant,
),
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
episodeDescription,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
const SizedBox(height: 6),
// Duration and release date row
Row(
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5),
Text(
episode.name,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
const SizedBox(width: 4),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_formatDuration(duration),
episodeDescription,
maxLines: isExpanded ? 100 : 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
color: colorScheme.onSurface.withOpacity(0.6),
),
),
],
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
const SizedBox(height: 6),
// Duration and release date row
Row(
children: [
if (duration != null && duration > Duration.zero) ...[
Icon(
Icons.access_time,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
const SizedBox(width: 4),
Text(
_formatDuration(duration),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
if (duration != null && duration > Duration.zero && releaseDate != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'•',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
if (releaseDate != null)
Text(
releaseDate,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
],
),
),
],
),
],
),
),
),
// 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,
@@ -1,504 +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 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.all(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),
),
],
);
},
),
),
);
}
}
+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
Widget build(BuildContext context) {
// Use select to only rebuild when selectedPlayer changes
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
final maProvider = context.watch<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
return Scaffold(
backgroundColor: const Color(0xFF1a1a1a),
@@ -153,7 +153,7 @@ class _QueueScreenState extends State<QueueScreen> {
IconButton(
icon: const Icon(Icons.swap_horiz_rounded),
tooltip: S.of(context)!.transferQueue,
onPressed: () => _handleTransferQueue(context.read<MusicAssistantProvider>()),
onPressed: () => _handleTransferQueue(maProvider),
),
IconButton(
icon: const Icon(Icons.refresh),
@@ -161,12 +161,11 @@ class _QueueScreenState extends State<QueueScreen> {
),
],
),
body: _buildBody(context),
body: _buildBody(maProvider),
);
}
Widget _buildBody(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
Widget _buildBody(MusicAssistantProvider maProvider) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
File diff suppressed because it is too large Load Diff
+69 -11
View File
@@ -7,7 +7,6 @@ import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../theme/app_theme.dart';
import '../utils/page_transitions.dart';
import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart';
import 'login_screen.dart';
@@ -40,7 +39,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Home row order
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
@@ -84,7 +85,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final rowOrder = await SettingsService.getHomeRowOrder();
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings
@@ -108,7 +111,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
@@ -262,8 +267,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
// Use select to only rebuild when connectionState changes
final connectionState = context.select<MusicAssistantProvider, MAConnectionState>((p) => p.connectionState);
final provider = context.watch<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -291,7 +295,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () {
Navigator.push(
context,
FadeSlidePageRoute(child: const DebugLogScreen()),
MaterialPageRoute(
builder: (context) => const DebugLogScreen(),
),
);
},
color: colorScheme.onBackground,
@@ -339,15 +345,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_getStatusIcon(connectionState),
color: _getStatusColor(connectionState, colorScheme),
_getStatusIcon(provider.connectionState),
color: _getStatusColor(provider.connectionState, colorScheme),
size: 20,
),
const SizedBox(width: 8),
Text(
_getStatusText(connectionState),
_getStatusText(provider.connectionState),
style: textTheme.titleMedium?.copyWith(
color: _getStatusColor(connectionState, colorScheme),
color: _getStatusColor(provider.connectionState, colorScheme),
fontWeight: FontWeight.bold,
),
),
@@ -697,6 +703,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
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(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -721,7 +754,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -730,11 +763,36 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: SwitchListTile(
title: Text(
S.of(context)!.volumePrecisionMode,
S.of(context)!.disableAutoSwitch,
style: TextStyle(color: colorScheme.onSurface),
),
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),
),
value: _volumePrecisionMode,
+483 -127
View File
@@ -1,186 +1,542 @@
import 'dart:convert';
import 'dart:io';
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 '../../constants/network.dart';
/// Simple authentication manager for Music Assistant
/// MA now uses its own built-in authentication system
/// Central authentication manager
/// Manages auth strategy selection, auto-detection, and credential lifecycle
class AuthManager {
final _logger = DebugLogger();
String? _accessToken;
String? _longLivedToken;
// Available auth strategies
final List<AuthStrategy> _strategies = [
NoAuthStrategy(),
BasicAuthStrategy(),
AutheliaStrategy(),
MusicAssistantAuthStrategy(),
];
/// Get the best available token (prefer long-lived)
String? get token => _longLivedToken ?? _accessToken;
AuthStrategy? _currentStrategy;
AuthCredentials? _currentCredentials;
/// Check if we have valid credentials
bool get hasCredentials => token != null;
/// Get current auth strategy (null if none selected)
AuthStrategy? get currentStrategy => _currentStrategy;
/// Login to Music Assistant and get an access token
Future<String?> login(String serverUrl, String username, String password) async {
try {
final apiUrl = _buildApiUrl(serverUrl);
_logger.log('🔐 Logging in to Music Assistant at $apiUrl');
/// Get current credentials (null if none stored)
AuthCredentials? get currentCredentials => _currentCredentials;
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));
/// 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;
_logger.log('Auth response status: ${response.statusCode}');
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
_lastSuccessfulUrl = null;
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Normalize URL
var baseUrl = _normalizeUrl(serverUrl);
_logger.log('Normalized URL: $baseUrl');
if (data.containsKey('error_code')) {
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
return null;
}
// Try detection with the initial URL
var result = await _tryDetectAuth(baseUrl);
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;
// 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;
}
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
return null;
} catch (e) {
_logger.log('✗ Login error: $e');
return null;
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;
}
}
/// Create a long-lived token for persistent authentication
Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
try {
_logger.log('Creating long-lived token...');
/// 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);
}
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',
},
}),
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 {
_logger.log('Probing server for auth headers...');
// Test root endpoint
final probeUrl = baseUrl;
_logger.log('Probe URL: $probeUrl');
final response = await http.get(
Uri.parse(probeUrl),
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
_logger.log('Probe status: ${response.statusCode}');
if (!data.containsKey('error_code')) {
final result = data['result'] as Map<String, dynamic>?;
final token = result?['token'] as String?;
// 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 (token != null) {
_logger.log('✓ Created long-lived token');
return token;
// 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,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
}
_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;
}
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
/// Validate a stored token
Future<bool> validateToken(String serverUrl, String storedToken) async {
try {
final apiUrl = _buildApiUrl(serverUrl);
// 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';
}
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $storedToken',
},
body: jsonEncode({
'command': 'server/info',
}),
).timeout(const Duration(seconds: 5));
// 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) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (!data.containsKey('error_code')) {
_longLivedToken = storedToken;
return true;
// Try to parse as JSON
try {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check if we got server info successfully (no auth required)
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;
} catch (e) {
_logger.log('Token validation failed: $e');
_logger.log('No-auth error: $e');
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() {
if (token != null) {
return {'Authorization': 'Bearer $token'};
if (_currentStrategy == null || _currentCredentials == null) {
return {};
}
return {};
return _currentStrategy!.buildWebSocketHeaders(_currentCredentials!);
}
/// Build headers for HTTP streaming requests
/// Get HTTP streaming headers for current credentials
Map<String, String> getStreamingHeaders() {
if (token != null) {
return {'Authorization': 'Bearer $token'};
if (_currentStrategy == null || _currentCredentials == null) {
return {};
}
return {};
return _currentStrategy!.buildStreamingHeaders(_currentCredentials!);
}
/// Set token from external source (e.g., restored from settings)
void setToken(String? storedToken) {
_longLivedToken = storedToken;
/// 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!),
};
}
/// Clear authentication state
/// 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() {
_accessToken = null;
_longLivedToken = null;
_currentStrategy = null;
_currentCredentials = null;
_logger.log('Logged out - cleared auth state');
}
/// Build API URL from server URL
Uri _buildApiUrl(String serverUrl) {
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'http://$baseUrl';
/// Get strategy by name (for manual selection)
AuthStrategy? getStrategyByName(String name) {
try {
return _strategies.firstWhere((s) => s.name == name);
} 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', {});
}
}
-27
View File
@@ -193,33 +193,6 @@ class DatabaseService {
return db.markItemsDeleted(itemType, [itemId]);
}
/// Mark a cached item as deleted by searching for library ID in the data
/// This handles cases where itemId is different from library ID
Future<void> markCachedItemDeletedByLibraryId(String itemType, String libraryId) async {
// First try direct match (for provider=library items)
await db.markItemsDeleted(itemType, [libraryId]);
// Also search through cached items to find any with matching library mapping
final items = await db.getCachedItems(itemType);
for (final item in items) {
try {
// Check if this item's data contains a library mapping with this ID
if (item.data.contains('"provider_instance":"library"') &&
item.data.contains('"item_id":"$libraryId"')) {
// Found a match - extract the actual cache key's itemId
final cacheKey = item.cacheKey;
final parts = cacheKey.split('_');
if (parts.length >= 2) {
final actualItemId = parts.sublist(1).join('_');
await db.markItemsDeleted(itemType, [actualItemId]);
}
}
} catch (_) {
// Ignore parse errors, continue checking other items
}
}
}
/// Batch cache multiple items efficiently
/// Uses drift's batch API for much better performance on large datasets.
/// Items are automatically chunked into batches of 500.
+38
View File
@@ -7,6 +7,9 @@ enum ErrorType {
network,
playback,
library,
remote, // Remote connection (WebRTC) errors
dataChannel, // WebRTC data channel errors
signaling, // WebRTC signaling server errors
unknown,
}
@@ -107,6 +110,41 @@ class ErrorHandler {
);
}
// WebRTC/Remote connection errors
if (errorStr.contains('webrtc') ||
errorStr.contains('remote peer') ||
errorStr.contains('remote server disconnected')) {
return ErrorInfo(
type: ErrorType.remote,
userMessage: 'Remote connection lost. Attempting to reconnect...',
technicalMessage: error.toString(),
canRetry: true,
);
}
// Data channel errors
if (errorStr.contains('data channel') ||
errorStr.contains('datachannel')) {
return ErrorInfo(
type: ErrorType.dataChannel,
userMessage: 'Remote data connection failed. Please try reconnecting.',
technicalMessage: error.toString(),
canRetry: true,
);
}
// Signaling errors
if (errorStr.contains('signaling') ||
errorStr.contains('ice') ||
errorStr.contains('peer connection failed')) {
return ErrorInfo(
type: ErrorType.signaling,
userMessage: 'Failed to establish remote connection. Please try again.',
technicalMessage: error.toString(),
canRetry: true,
);
}
// Unknown errors
return ErrorInfo(
type: ErrorType.unknown,
-177
View File
@@ -1,177 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Centralized service for tracking library and favorite status of media items.
///
/// This service implements the "single source of truth" pattern:
/// - All widgets query this service instead of maintaining local state
/// - Status changes notify all listeners immediately (optimistic updates)
/// - API failures trigger rollback notifications
///
/// Usage:
/// ```dart
/// // Check status
/// final isInLibrary = LibraryStatusService.instance.isInLibrary(itemKey);
///
/// // Listen to changes
/// LibraryStatusService.instance.addListener(() {
/// // Rebuild UI
/// });
///
/// // Update status (optimistically)
/// LibraryStatusService.instance.setLibraryStatus(itemKey, true);
/// ```
class LibraryStatusService extends ChangeNotifier {
LibraryStatusService._();
static final LibraryStatusService instance = LibraryStatusService._();
// Status maps: itemKey -> status
// itemKey format: "mediaType:provider:itemId" for uniqueness
final Map<String, bool> _libraryStatus = {};
final Map<String, bool> _favoriteStatus = {};
// Pending operations for rollback on failure
final Map<String, bool> _pendingLibraryOps = {};
final Map<String, bool> _pendingFavoriteOps = {};
/// Generate a unique key for an item
static String makeKey(String mediaType, String provider, String itemId) {
return '$mediaType:$provider:$itemId';
}
/// Check if item is in library
bool isInLibrary(String key) => _libraryStatus[key] ?? false;
/// Check if item is favorite
bool isFavorite(String key) => _favoriteStatus[key] ?? false;
/// Set library status (optimistic update)
/// Call this immediately when user taps the button
void setLibraryStatus(String key, bool inLibrary) {
if (_libraryStatus[key] == inLibrary) return;
_libraryStatus[key] = inLibrary;
notifyListeners();
}
/// Set favorite status (optimistic update)
void setFavoriteStatus(String key, bool favorite) {
if (_favoriteStatus[key] == favorite) return;
_favoriteStatus[key] = favorite;
notifyListeners();
}
/// Mark operation as pending (for rollback tracking)
void markLibraryPending(String key, bool previousValue) {
_pendingLibraryOps[key] = previousValue;
}
void markFavoritePending(String key, bool previousValue) {
_pendingFavoriteOps[key] = previousValue;
}
/// Complete operation successfully (clear pending)
void completeLibraryOperation(String key) {
_pendingLibraryOps.remove(key);
}
void completeFavoriteOperation(String key) {
_pendingFavoriteOps.remove(key);
}
/// Rollback operation on failure
void rollbackLibraryOperation(String key) {
final previousValue = _pendingLibraryOps.remove(key);
if (previousValue != null) {
_libraryStatus[key] = previousValue;
notifyListeners();
}
}
void rollbackFavoriteOperation(String key) {
final previousValue = _pendingFavoriteOps.remove(key);
if (previousValue != null) {
_favoriteStatus[key] = previousValue;
notifyListeners();
}
}
/// Bulk update from provider's library data
/// Called when library is loaded/refreshed
void syncFromLibrary({
required String mediaType,
required List<dynamic> items,
required bool Function(dynamic item) getInLibrary,
required bool Function(dynamic item) getFavorite,
required String Function(dynamic item) getProvider,
required String Function(dynamic item) getItemId,
}) {
bool changed = false;
for (final item in items) {
final key = makeKey(mediaType, getProvider(item), getItemId(item));
final inLibrary = getInLibrary(item);
final favorite = getFavorite(item);
// Only update if not pending (don't overwrite optimistic updates)
if (!_pendingLibraryOps.containsKey(key)) {
if (_libraryStatus[key] != inLibrary) {
_libraryStatus[key] = inLibrary;
changed = true;
}
}
if (!_pendingFavoriteOps.containsKey(key)) {
if (_favoriteStatus[key] != favorite) {
_favoriteStatus[key] = favorite;
changed = true;
}
}
}
if (changed) {
notifyListeners();
}
}
/// Update a single item's status from fresh data
void syncSingleItem({
required String key,
required bool inLibrary,
required bool favorite,
}) {
bool changed = false;
if (!_pendingLibraryOps.containsKey(key) && _libraryStatus[key] != inLibrary) {
_libraryStatus[key] = inLibrary;
changed = true;
}
if (!_pendingFavoriteOps.containsKey(key) && _favoriteStatus[key] != favorite) {
_favoriteStatus[key] = favorite;
changed = true;
}
if (changed) {
notifyListeners();
}
}
/// Clear all status (on logout/disconnect)
void clear() {
_libraryStatus.clear();
_favoriteStatus.clear();
_pendingLibraryOps.clear();
_pendingFavoriteOps.clear();
notifyListeners();
}
/// Get debug info
Map<String, dynamic> get debugInfo => {
'libraryCount': _libraryStatus.length,
'favoriteCount': _favoriteStatus.length,
'pendingLibrary': _pendingLibraryOps.length,
'pendingFavorite': _pendingFavoriteOps.length,
};
}
+158 -38
View File
@@ -25,6 +25,20 @@ enum MAConnectionState {
error,
}
/// Wrapper for pending requests with timestamp for age-based cleanup
class _PendingRequest {
final Completer<Map<String, dynamic>> completer;
final DateTime createdAt;
_PendingRequest({required this.completer, DateTime? createdAt})
: createdAt = createdAt ?? DateTime.now();
bool get isStale {
const staleThreshold = Duration(minutes: 2);
return DateTime.now().difference(createdAt) > staleThreshold;
}
}
class MusicAssistantAPI {
final String serverUrl;
final AuthManager authManager;
@@ -38,7 +52,7 @@ class MusicAssistantAPI {
MAConnectionState _currentState = MAConnectionState.disconnected;
MAConnectionState get currentConnectionState => _currentState;
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
final Map<String, _PendingRequest> _pendingRequests = {};
final Map<String, StreamController<Map<String, dynamic>>> _eventStreams = {};
Completer<void>? _connectionCompleter;
@@ -65,6 +79,12 @@ class MusicAssistantAPI {
final Map<String, ProviderManifest> _providerManifests = {};
Map<String, ProviderManifest> get providerManifests => _providerManifests;
// Remote mode flag - when true, images go through WebRTC bridge which doesn't support HTTP
// In this mode, we use external CDN URLs directly instead of imageproxy
bool _isRemoteMode = false;
bool get isRemoteMode => _isRemoteMode;
set isRemoteMode(bool value) => _isRemoteMode = value;
MusicAssistantAPI(this.serverUrl, this.authManager);
// Guard to prevent multiple simultaneous connection attempts
@@ -285,15 +305,15 @@ class MusicAssistantAPI {
// Handle response to a request
if (messageId != null && _pendingRequests.containsKey(messageId)) {
final completer = _pendingRequests.remove(messageId);
final pending = _pendingRequests.remove(messageId);
if (data.containsKey('error_code')) {
_logger.log('Command error: ${data['error_code']} - ${data['details']}');
completer!.completeError(
pending!.completer.completeError(
Exception('${data['error_code']}: ${data['details']}'),
);
} else {
completer!.complete(data);
pending!.completer.complete(data);
}
return;
}
@@ -352,6 +372,9 @@ class MusicAssistantAPI {
}
}
// Clean up stale requests before checking limits
_cleanupStaleRequests();
// Prevent memory leaks by limiting pending requests
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
throw Exception(
@@ -362,7 +385,7 @@ class MusicAssistantAPI {
final messageId = _uuid.v4();
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[messageId] = completer;
_pendingRequests[messageId] = _PendingRequest(completer: completer);
final message = {
'message_id': messageId,
@@ -385,6 +408,27 @@ class MusicAssistantAPI {
}
}
/// Clean up stale pending requests that have been waiting too long
void _cleanupStaleRequests() {
final staleIds = <String>[];
for (final entry in _pendingRequests.entries) {
if (entry.value.isStale && !entry.value.completer.isCompleted) {
staleIds.add(entry.key);
}
}
if (staleIds.isNotEmpty) {
_logger.log('Cleanup: Removing ${staleIds.length} stale pending requests');
for (final id in staleIds) {
final pending = _pendingRequests.remove(id);
if (pending != null && !pending.completer.isCompleted) {
pending.completer.completeError(
TimeoutException('Request expired after 2 minutes'),
);
}
}
}
}
// Library browsing methods
Future<List<Artist>> getArtists({
int? limit,
@@ -2076,37 +2120,6 @@ class MusicAssistantAPI {
}
}
/// Add multiple tracks to queue without interrupting playback
Future<void> addTracksToQueue(String playerId, List<Track> tracks, {int startIndex = 0}) async {
try {
final tracksToAdd = startIndex > 0 ? tracks.sublist(startIndex) : tracks;
// Filter to only tracks with available provider mappings
final playableTracks = tracksToAdd.where((track) {
if (track.providerMappings == null || track.providerMappings!.isEmpty) {
return false;
}
return track.providerMappings!.any((m) => m.available);
}).toList();
if (playableTracks.isEmpty) return;
final uris = playableTracks.map((track) => _buildTrackUri(track)).toList();
await _sendCommand(
'player_queues/play_media',
args: {
'queue_id': playerId,
'media': uris,
'option': 'add', // Add to queue without starting playback
},
);
} catch (e) {
_logger.log('Error adding tracks to queue: $e');
rethrow;
}
}
/// Play album on player
Future<void> playAlbum(String playerId, Album album) async {
try {
@@ -3247,6 +3260,12 @@ class MusicAssistantAPI {
return null;
}
// In remote mode (WebRTC bridge), HTTP requests don't work through the tunnel
// So we need to use external CDN URLs directly when available
if (_isRemoteMode) {
return _getImageUrlForRemoteMode(images, size);
}
// Try to find a non-remotely accessible image first (prefer local/opensubsonic)
Map<String, dynamic>? selectedImage;
for (var img in images) {
@@ -3309,6 +3328,107 @@ class MusicAssistantAPI {
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
}
/// Get image URL for remote mode - uses external CDN URLs directly
/// since HTTP requests don't work through the WebRTC bridge
String? _getImageUrlForRemoteMode(List<dynamic> images, int size) {
// Known external CDN domains that are publicly accessible
const externalDomains = [
'i.scdn.co', // Spotify
'mosaic.scdn.co', // Spotify playlists
'image-cdn-ak.spotifycdn.com', // Spotify
'cdns-images.dzcdn.net', // Deezer
'resources.tidal.com', // Tidal
'e-cdns-images.dzcdn.net', // Deezer
'is1-ssl.mzstatic.com', // Apple Music
'is2-ssl.mzstatic.com', // Apple Music
'is3-ssl.mzstatic.com', // Apple Music
'is4-ssl.mzstatic.com', // Apple Music
'is5-ssl.mzstatic.com', // Apple Music
'lastfm.freetls.fastly.net', // Last.fm
'coverartarchive.org', // MusicBrainz
'archive.org', // Internet Archive
'fanart.tv', // Fanart.tv
];
// First pass: look for images with paths that are external URLs
for (var img in images) {
final imgMap = img as Map<String, dynamic>;
final path = imgMap['path'] as String?;
if (path == null) continue;
// Check if path is already an external URL
if (path.startsWith('https://')) {
try {
final uri = Uri.parse(path);
// If URL contains imageproxy with a size parameter, replace with requested size
if (uri.path.contains('imageproxy') && uri.queryParameters.containsKey('size')) {
final newParams = Map<String, String>.from(uri.queryParameters);
newParams['size'] = size.toString();
final newUri = uri.replace(queryParameters: newParams);
_logger.log('🖼️ Remote mode: adjusted imageproxy size to $size from ${uri.host}');
return newUri.toString();
}
// Check if it's from a known CDN
for (final domain in externalDomains) {
if (uri.host.contains(domain)) {
_logger.log('🖼️ Remote mode: using external CDN URL from ${uri.host}');
// For Spotify, we can request different sizes
if (uri.host.contains('scdn.co') || uri.host.contains('spotifycdn.com')) {
// Spotify image URLs can have size in path, but we'll use as-is
return path;
}
return path;
}
}
// Check if it's an Audiobookshelf URL (cover API endpoint)
// Audiobookshelf supports ?width=X parameter for resizing
if (uri.path.contains('/api/') && uri.path.contains('/cover')) {
final newParams = Map<String, String>.from(uri.queryParameters);
newParams['width'] = size.toString();
final newUri = uri.replace(queryParameters: newParams);
_logger.log('🖼️ Remote mode: adjusted Audiobookshelf cover size to $size from ${uri.host}');
return newUri.toString();
}
// Even if not a known CDN, if it's HTTPS it might work
_logger.log('🖼️ Remote mode: trying external URL from ${uri.host}');
return path;
} catch (e) {
// Not a valid URL, continue
}
}
}
// Second pass: try any image with a provider that typically has external URLs
for (var img in images) {
final imgMap = img as Map<String, dynamic>;
final provider = imgMap['provider'] as String?;
final path = imgMap['path'] as String?;
if (path == null) continue;
// Providers that typically have external URLs in their paths
if (provider != null) {
if (provider.contains('spotify') ||
provider.contains('tidal') ||
provider.contains('deezer') ||
provider.contains('apple_music') ||
provider.contains('musicbrainz')) {
if (path.startsWith('https://')) {
_logger.log('🖼️ Remote mode: using $provider image URL');
return path;
}
}
}
}
// No external URLs found - images won't load in remote mode for local files
_logger.log('🖼️ Remote mode: no external image URL available');
return null;
}
// ==================== iTunes Artwork Lookup ====================
/// Search iTunes for a podcast and return high-res artwork URL
@@ -3708,8 +3828,8 @@ class MusicAssistantAPI {
/// Cancel all pending requests with an error message
void _cancelPendingRequests(String reason) {
for (final entry in _pendingRequests.entries) {
if (!entry.value.isCompleted) {
entry.value.completeError(Exception(reason));
if (!entry.value.completer.isCompleted) {
entry.value.completer.completeError(Exception(reason));
}
}
_pendingRequests.clear();
+675
View File
@@ -0,0 +1,675 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:ensemble/services/remote/webview_webrtc_connection.dart';
import 'package:ensemble/services/debug_logger.dart';
/// Error types for RemoteBridge
enum RemoteBridgeErrorType {
webrtcConnection,
signaling,
authentication,
dataChannel,
timeout,
unknown,
}
/// Error information from RemoteBridge
class RemoteBridgeError {
final RemoteBridgeErrorType type;
final String message;
final DateTime timestamp;
RemoteBridgeError({
required this.type,
required this.message,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
}
/// Connection state for RemoteBridge
enum RemoteBridgeState {
disconnected,
connecting,
authenticating,
connected,
reconnecting,
failed,
}
/// Connection quality metrics
class ConnectionMetrics {
final int requestsSent;
final int responsesReceived;
final int reconnectionCount;
final Duration? lastLatency;
final DateTime? lastActivity;
ConnectionMetrics({
required this.requestsSent,
required this.responsesReceived,
required this.reconnectionCount,
this.lastLatency,
this.lastActivity,
});
double get successRate {
if (requestsSent == 0) return 1.0;
return responsesReceived / requestsSent;
}
}
/// A WebRTC-to-WebSocket bridge that allows MusicAssistantAPI to work unchanged
/// for remote connections. All traffic is tunneled through a local WebSocket server.
///
/// Architecture:
/// ```
/// MusicAssistantAPI → localhost:port → RemoteBridge → WebRTC → MA Server
/// Sendspin → localhost:port/sendspin → RemoteBridge → WebRTC (sendspin channel) → MA Server
/// ```
class RemoteBridge {
HttpServer? _server;
WebViewWebRTCConnection? _webrtcConnection;
int? _port;
// API client (MusicAssistantAPI)
WebSocket? _apiClientSocket;
StreamSubscription? _apiClientSubscription;
StreamSubscription? _apiWebrtcSubscription;
// Sendspin client (local player audio)
WebSocket? _sendspinClientSocket;
StreamSubscription? _sendspinClientSubscription;
StreamSubscription? _sendspinTextSubscription;
StreamSubscription? _sendspinBinarySubscription;
Timer? _healthCheckTimer;
Timer? _reconnectTimer;
// Connection parameters for reconnection
String? _remoteId;
String? _username;
String? _password;
bool _isReconnecting = false;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
static const Duration _reconnectDelay = Duration(seconds: 3);
static const Duration _rateLimitDelay = Duration(seconds: 65);
bool _isRateLimited = false;
// Cached server hello to replay when WS client connects
String? _cachedServerHello;
// Debug counters
int _apiMessagesSent = 0;
int _apiMessagesReceived = 0;
int _sendspinMessagesSent = 0;
int _sendspinMessagesReceived = 0;
DateTime? _lastMessageReceived;
int _totalReconnections = 0;
// State tracking
RemoteBridgeState _state = RemoteBridgeState.disconnected;
final _stateController = StreamController<RemoteBridgeState>.broadcast();
final _errorController = StreamController<RemoteBridgeError>.broadcast();
final DebugLogger _logger = DebugLogger();
int? get port => _port;
bool get isRunning => _server != null;
bool get isSendspinReady => _webrtcConnection?.isSendspinConnected ?? false;
bool get isWebRTCConnected => _webrtcConnection?.isConnected ?? false;
RemoteBridgeState get state => _state;
Stream<RemoteBridgeState> get stateStream => _stateController.stream;
Stream<RemoteBridgeError> get errorStream => _errorController.stream;
/// Build the hidden WebView widget that must be added to the widget tree.
/// This must be called and the returned widget added to the tree BEFORE calling start().
/// The WebView is 1x1 pixel and invisible.
Widget buildWebView() {
// Create the connection if not already created
_webrtcConnection ??= WebViewWebRTCConnection();
return _webrtcConnection!.buildWebView();
}
/// Get current connection metrics
ConnectionMetrics get metrics => ConnectionMetrics(
requestsSent: _apiMessagesSent,
responsesReceived: _apiMessagesReceived,
reconnectionCount: _totalReconnections,
lastActivity: _lastMessageReceived,
);
/// Update state and notify listeners
void _setState(RemoteBridgeState newState) {
if (_state != newState) {
_logger.log('RemoteBridge: State changed: $_state -> $newState');
_state = newState;
_stateController.add(newState);
}
}
/// Emit an error to listeners
void _emitError(RemoteBridgeErrorType type, String message) {
final error = RemoteBridgeError(type: type, message: message);
_logger.log('RemoteBridge: Error [$type]: $message');
_errorController.add(error);
}
/// Start the bridge and connect to the remote MA server.
/// Returns the local port number to connect to, or null if failed.
Future<int?> start(String remoteId, String username, String password) async {
_logger.log('RemoteBridge: Starting bridge for remote ID: $remoteId');
_setState(RemoteBridgeState.connecting);
// Store connection parameters for reconnection
_remoteId = remoteId;
_username = username;
_password = password;
_reconnectAttempts = 0;
try {
// Step 1: Connect to MA via WebRTC (using WebView-based implementation)
// The WebView must be in the widget tree first - call buildWebView() before start()
_webrtcConnection ??= WebViewWebRTCConnection();
_webrtcConnection!.onStateChanged = (state) {
_logger.log('RemoteBridge: WebRTC state: $state');
if (state == WebViewWebRTCConnectionState.disconnected ||
state == WebViewWebRTCConnectionState.failed) {
_handleWebRTCDisconnection();
}
};
_webrtcConnection!.onError = (error) {
_logger.log('RemoteBridge: WebRTC error: $error');
_emitError(RemoteBridgeErrorType.webrtcConnection, error);
};
final connected = await _webrtcConnection!.connect(remoteId);
if (!connected) {
_logger.log('RemoteBridge: WebRTC connection failed');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Failed to connect to remote server');
await stop();
return null;
}
_logger.log('RemoteBridge: WebRTC connected, authenticating...');
_setState(RemoteBridgeState.authenticating);
// Step 2: Authenticate with MA
final authenticated = await _authenticate(username, password);
if (!authenticated) {
_logger.log('RemoteBridge: Authentication failed');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.authentication, 'Authentication failed');
await stop();
return null;
}
_logger.log('RemoteBridge: Authentication successful');
_setState(RemoteBridgeState.connected);
// Step 3: Cache the server hello (it was already received by WebRTCConnection)
if (_webrtcConnection!.serverInfo != null) {
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
_logger.log('RemoteBridge: Cached server hello');
}
// Step 4: Start local WebSocket server
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_port = _server!.port;
_logger.log('RemoteBridge: Local WS server started on port $_port');
// Handle incoming connections
_server!.listen(_handleHttpRequest);
// Step 5: Subscribe to WebRTC API messages to forward to API client
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
_apiMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_apiClientSocket != null) {
_apiClientSocket!.add(message);
}
});
// Step 6: Subscribe to Sendspin WebRTC messages (text) to forward to Sendspin client
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
_sendspinMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_sendspinClientSocket != null) {
_sendspinClientSocket!.add(message);
}
});
// Step 7: Subscribe to Sendspin WebRTC messages (binary audio) to forward to Sendspin client
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
_sendspinMessagesReceived++;
if (_sendspinClientSocket != null) {
// Forward binary audio data to Sendspin client
_sendspinClientSocket!.add(data);
}
});
// Step 8: Start health check timer
_startHealthCheck();
return _port;
} catch (e) {
_logger.log('RemoteBridge: Start failed: $e');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.unknown, e.toString());
await stop();
return null;
}
}
/// Authenticate with the MA server over WebRTC.
Future<bool> _authenticate(String username, String password) async {
try {
// Step 1: Login to get access token
_logger.log('RemoteBridge: Sending auth/login...');
final loginResult = await _webrtcConnection!.sendRequest('auth/login', {
'username': username,
'password': password,
'device_name': 'Ensemble Mobile App',
});
_logger.log('RemoteBridge: Login result: $loginResult');
// Check for error response
if (loginResult.containsKey('error_code')) {
_logger.log('RemoteBridge: Login error: ${loginResult['details']}');
return false;
}
String? accessToken;
if (loginResult['result'] != null) {
final result = loginResult['result'];
if (result is Map<String, dynamic>) {
if (result['success'] == true) {
accessToken = result['access_token'] as String?;
final user = result['user'] as Map<String, dynamic>?;
_logger.log('RemoteBridge: Login successful! User: ${user?['username']}');
}
}
}
if (accessToken == null) {
_logger.log('RemoteBridge: No access token received');
return false;
}
// Step 2: Authenticate the session with the token
_logger.log('RemoteBridge: Authenticating session with token...');
final authResult = await _webrtcConnection!.sendRequest('auth', {
'token': accessToken,
});
_logger.log('RemoteBridge: Auth result: $authResult');
if (authResult.containsKey('error_code')) {
_logger.log('RemoteBridge: Session auth failed: ${authResult['details']}');
return false;
}
_logger.log('RemoteBridge: Session authenticated successfully!');
return true;
} catch (e) {
_logger.log('RemoteBridge: Auth error: $e');
return false;
}
}
/// Handle incoming HTTP requests and upgrade to WebSocket.
void _handleHttpRequest(HttpRequest request) async {
final path = request.uri.path;
_logger.log('RemoteBridge: Incoming HTTP request: $path');
if (WebSocketTransformer.isUpgradeRequest(request)) {
try {
final socket = await WebSocketTransformer.upgrade(request);
// Route based on path: /sendspin for audio, anything else for API
if (path == '/sendspin') {
_handleSendspinConnection(socket);
} else {
_handleApiConnection(socket);
}
} catch (e) {
_logger.log('RemoteBridge: WebSocket upgrade failed: $e');
request.response.statusCode = HttpStatus.internalServerError;
await request.response.close();
}
} else {
// Not a WebSocket request
request.response.statusCode = HttpStatus.badRequest;
await request.response.close();
}
}
/// Handle a new API WebSocket client connection.
void _handleApiConnection(WebSocket socket) {
_logger.log('RemoteBridge: API client connected');
// Only allow one API client at a time
if (_apiClientSocket != null) {
_logger.log('RemoteBridge: Closing existing API client connection');
_apiClientSubscription?.cancel();
_apiClientSocket?.close();
}
_apiClientSocket = socket;
// Step 1: Send cached server hello immediately
if (_cachedServerHello != null) {
_logger.log('RemoteBridge: Sending cached server hello to API client');
_apiClientSocket!.add(_cachedServerHello!);
}
// Step 2: Forward all messages from API client to WebRTC (ma-api channel)
_apiClientSubscription = _apiClientSocket!.listen(
(message) {
if (message is String && _webrtcConnection != null) {
_apiMessagesSent++;
_webrtcConnection!.sendRaw(message);
}
},
onDone: () {
_logger.log('RemoteBridge: API client disconnected');
_apiClientSocket = null;
_apiClientSubscription = null;
},
onError: (error) {
_logger.log('RemoteBridge: API client error: $error');
_apiClientSocket = null;
_apiClientSubscription = null;
},
);
}
/// Handle a new Sendspin WebSocket client connection (for audio streaming).
void _handleSendspinConnection(WebSocket socket) {
_logger.log('RemoteBridge: Sendspin client connected');
// Only allow one Sendspin client at a time
if (_sendspinClientSocket != null) {
_logger.log('RemoteBridge: Closing existing Sendspin client connection');
_sendspinClientSubscription?.cancel();
_sendspinClientSocket?.close();
}
_sendspinClientSocket = socket;
// Forward all messages from Sendspin client to WebRTC (sendspin channel)
_sendspinClientSubscription = _sendspinClientSocket!.listen(
(message) {
if (_webrtcConnection != null) {
_sendspinMessagesSent++;
if (message is String) {
// Text message (JSON control)
_webrtcConnection!.sendSendspinText(message);
} else if (message is List<int>) {
// Binary message (would be audio data from client, unlikely but handle it)
_webrtcConnection!.sendSendspinBinary(Uint8List.fromList(message));
}
}
},
onDone: () {
_logger.log('RemoteBridge: Sendspin client disconnected');
_sendspinClientSocket = null;
_sendspinClientSubscription = null;
},
onError: (error) {
_logger.log('RemoteBridge: Sendspin client error: $error');
_sendspinClientSocket = null;
_sendspinClientSubscription = null;
},
);
}
/// Handle WebRTC disconnection - attempt reconnection.
void _handleWebRTCDisconnection() {
_logger.log('RemoteBridge: WebRTC disconnected');
// Don't close WS clients immediately - they will reconnect when WebRTC is back
// Instead, attempt to reconnect WebRTC
_scheduleReconnect();
}
/// Schedule a WebRTC reconnection attempt.
void _scheduleReconnect() {
if (_isReconnecting) {
_logger.log('RemoteBridge: Already reconnecting, skipping');
return;
}
if (_remoteId == null || _username == null || _password == null) {
_logger.log('RemoteBridge: No connection parameters, cannot reconnect');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Cannot reconnect - no connection parameters');
_closeAllClients();
return;
}
if (_reconnectAttempts >= _maxReconnectAttempts) {
_logger.log('RemoteBridge: Max reconnect attempts reached, giving up');
_setState(RemoteBridgeState.failed);
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Max reconnection attempts reached');
_closeAllClients();
return;
}
_setState(RemoteBridgeState.reconnecting);
_reconnectAttempts++;
_totalReconnections++;
// Use longer delay if rate limited, otherwise exponential backoff
final Duration delay;
if (_isRateLimited) {
delay = _rateLimitDelay;
_logger.log('RemoteBridge: Rate limited - waiting ${delay.inSeconds}s before reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts');
} else {
delay = _reconnectDelay * _reconnectAttempts;
_logger.log('RemoteBridge: Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
}
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, _reconnectWebRTC);
}
/// Attempt to reconnect WebRTC.
Future<void> _reconnectWebRTC() async {
if (_isReconnecting) return;
_isReconnecting = true;
_logger.log('RemoteBridge: Attempting WebRTC reconnection...');
try {
// Clean up subscriptions but keep the WebView connection (it's still in the widget tree)
_apiWebrtcSubscription?.cancel();
_sendspinTextSubscription?.cancel();
_sendspinBinarySubscription?.cancel();
await _webrtcConnection?.disconnect();
// Reuse existing WebView connection (WebView is already in widget tree)
// If somehow null, create a new one (shouldn't happen in normal flow)
_webrtcConnection ??= WebViewWebRTCConnection();
_webrtcConnection!.onStateChanged = (state) {
_logger.log('RemoteBridge: WebRTC state (reconnect): $state');
if (state == WebViewWebRTCConnectionState.disconnected ||
state == WebViewWebRTCConnectionState.failed) {
_isReconnecting = false;
_handleWebRTCDisconnection();
} else if (state == WebViewWebRTCConnectionState.connected) {
_reconnectAttempts = 0; // Reset on successful connection
}
};
_webrtcConnection!.onError = (error) {
_logger.log('RemoteBridge: WebRTC error (reconnect): $error');
// Detect rate limiting from signaling server
if (error.toLowerCase().contains('rate limit')) {
_logger.log('RemoteBridge: Rate limited - will wait ${_rateLimitDelay.inSeconds}s before retry');
_isRateLimited = true;
}
};
final connected = await _webrtcConnection!.connect(_remoteId!);
if (!connected) {
_logger.log('RemoteBridge: WebRTC reconnection failed');
_isReconnecting = false;
_scheduleReconnect();
return;
}
_logger.log('RemoteBridge: WebRTC reconnected, re-authenticating...');
// Re-authenticate
final authenticated = await _authenticate(_username!, _password!);
if (!authenticated) {
_logger.log('RemoteBridge: Re-authentication failed');
_isReconnecting = false;
_scheduleReconnect();
return;
}
_logger.log('RemoteBridge: Re-authentication successful');
// Re-cache server hello
if (_webrtcConnection!.serverInfo != null) {
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
_logger.log('RemoteBridge: Re-cached server hello');
}
// Re-subscribe to WebRTC streams
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
_apiMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_apiClientSocket != null) {
_apiClientSocket!.add(message);
}
});
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
_sendspinMessagesReceived++;
_lastMessageReceived = DateTime.now();
if (_sendspinClientSocket != null) {
_sendspinClientSocket!.add(message);
}
});
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
_sendspinMessagesReceived++;
if (_sendspinClientSocket != null) {
_sendspinClientSocket!.add(data);
}
});
_logger.log('RemoteBridge: ✅ WebRTC reconnection complete');
_setState(RemoteBridgeState.connected);
_isReconnecting = false;
_reconnectAttempts = 0;
_isRateLimited = false;
} catch (e) {
_logger.log('RemoteBridge: Reconnection error: $e');
_emitError(RemoteBridgeErrorType.webrtcConnection, e.toString());
_isReconnecting = false;
_scheduleReconnect();
}
}
/// Close all WS clients (called when giving up on reconnection).
void _closeAllClients() {
_logger.log('RemoteBridge: Closing all WS clients');
_apiClientSocket?.close();
_apiClientSocket = null;
_apiClientSubscription?.cancel();
_apiClientSubscription = null;
_sendspinClientSocket?.close();
_sendspinClientSocket = null;
_sendspinClientSubscription?.cancel();
_sendspinClientSubscription = null;
}
/// Start periodic health check to monitor connection state
void _startHealthCheck() {
_healthCheckTimer?.cancel();
_healthCheckTimer = Timer.periodic(const Duration(seconds: 10), (_) {
final dcState = _webrtcConnection?.dataChannelStateDebug ?? 'no connection';
final sendspinState = _webrtcConnection?.isSendspinConnected ?? false;
final wcState = _webrtcConnection?.state.toString() ?? 'no connection';
final timeSinceLastMsg = _lastMessageReceived != null
? DateTime.now().difference(_lastMessageReceived!).inSeconds
: -1;
_logger.log('RemoteBridge: Health check - API DC:$dcState, Sendspin:$sendspinState, WC:$wcState, '
'API sent:$_apiMessagesSent recv:$_apiMessagesReceived, '
'Sendspin sent:$_sendspinMessagesSent recv:$_sendspinMessagesReceived, '
'lastRecv:${timeSinceLastMsg}s ago');
});
}
/// Stop the bridge and disconnect from the remote server.
Future<void> stop() async {
_logger.log('RemoteBridge: Stopping bridge');
_setState(RemoteBridgeState.disconnected);
// Cancel reconnection
_reconnectTimer?.cancel();
_reconnectTimer = null;
_isReconnecting = false;
_reconnectAttempts = 0;
_healthCheckTimer?.cancel();
_healthCheckTimer = null;
// Cancel API subscriptions
_apiClientSubscription?.cancel();
_apiClientSubscription = null;
_apiWebrtcSubscription?.cancel();
_apiWebrtcSubscription = null;
// Cancel Sendspin subscriptions
_sendspinClientSubscription?.cancel();
_sendspinClientSubscription = null;
_sendspinTextSubscription?.cancel();
_sendspinTextSubscription = null;
_sendspinBinarySubscription?.cancel();
_sendspinBinarySubscription = null;
// Close client sockets
await _apiClientSocket?.close();
_apiClientSocket = null;
await _sendspinClientSocket?.close();
_sendspinClientSocket = null;
await _server?.close(force: true);
_server = null;
_port = null;
await _webrtcConnection?.disconnect();
_webrtcConnection = null;
_cachedServerHello = null;
// Clear connection parameters
_remoteId = null;
_username = null;
_password = null;
_logger.log('RemoteBridge: Bridge stopped');
}
/// Dispose the bridge and close all stream controllers.
void dispose() {
stop();
_stateController.close();
_errorController.close();
}
}
@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:ensemble/services/remote/webrtc_connection.dart';
import 'package:ensemble/services/debug_logger.dart';
/// Manages remote connections to MA servers via WebRTC
class RemoteConnectionManager {
final WebRTCConnection _connection = WebRTCConnection();
StreamSubscription? _messageSubscription;
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
bool _isAuthenticated = false;
String? _serverVersion;
// Callbacks
Function(bool connected)? onConnectionChanged;
Function(String error)? onError;
bool get isConnected => _connection.isConnected && _isAuthenticated;
Stream<Map<String, dynamic>> get events => _eventController.stream;
String? get serverVersion => _serverVersion;
RemoteConnectionManager() {
_connection.onStateChanged = _onConnectionStateChanged;
_connection.onError = (error) => onError?.call(error);
// Forward events from the data channel
_messageSubscription = _connection.messages.listen((message) {
// Check if this is an event (has 'event' key)
if (message.containsKey('event')) {
_eventController.add(message);
}
});
}
/// Connect to a remote MA server using Remote Access ID
Future<bool> connect(String remoteId, String username, String password) async {
DebugLogger().log('RemoteConnection: Connecting to remote ID: $remoteId');
// Connect WebRTC
final connected = await _connection.connect(remoteId);
if (!connected) {
DebugLogger().log('RemoteConnection: WebRTC connection failed');
return false;
}
DebugLogger().log('RemoteConnection: WebRTC connected, authenticating...');
// Authenticate with MA
try {
final authenticated = await _authenticate(username, password);
if (!authenticated) {
DebugLogger().log('RemoteConnection: Authentication failed');
await _connection.disconnect();
return false;
}
_isAuthenticated = true;
DebugLogger().log('RemoteConnection: Fully connected and authenticated');
onConnectionChanged?.call(true);
return true;
} catch (e) {
DebugLogger().log('RemoteConnection: Auth error: $e');
await _connection.disconnect();
return false;
}
}
/// Authenticate with the MA server
Future<bool> _authenticate(String username, String password) async {
try {
// Step 1: Login to get access token
DebugLogger().log('RemoteConnection: Sending auth/login...');
final loginResult = await _connection.sendRequest('auth/login', {
'username': username,
'password': password,
'device_name': 'Ensemble Mobile App',
});
DebugLogger().log('RemoteConnection: Login result: $loginResult');
// Check for error response
if (loginResult.containsKey('error_code')) {
DebugLogger().log('RemoteConnection: Login error: ${loginResult['details']}');
return false;
}
String? accessToken;
if (loginResult['result'] != null) {
final result = loginResult['result'];
if (result is Map<String, dynamic>) {
if (result['success'] == true) {
accessToken = result['access_token'] as String?;
final user = result['user'] as Map<String, dynamic>?;
DebugLogger().log('RemoteConnection: Login successful!');
DebugLogger().log('RemoteConnection: User: ${user?['username']} (${user?['role']})');
}
}
}
if (accessToken == null) {
DebugLogger().log('RemoteConnection: No access token received');
return false;
}
// Step 2: Authenticate the session with the token
DebugLogger().log('RemoteConnection: Authenticating session with token...');
final authResult = await _connection.sendRequest('auth', {
'token': accessToken,
});
DebugLogger().log('RemoteConnection: Auth result: $authResult');
if (authResult.containsKey('error_code')) {
DebugLogger().log('RemoteConnection: Session auth failed: ${authResult['details']}');
return false;
}
// Store the token for later use
_accessToken = accessToken;
DebugLogger().log('RemoteConnection: Session authenticated successfully!');
return true;
} catch (e) {
DebugLogger().log('RemoteConnection: Login error: $e');
return false;
}
}
String? _accessToken;
/// Send a command to the MA server
Future<Map<String, dynamic>> sendCommand(
String command, [
Map<String, dynamic>? params,
]) async {
if (!_connection.isConnected) {
throw Exception('Not connected');
}
return await _connection.sendRequest(command, params);
}
/// Subscribe to events from the server
Future<void> subscribe(List<String> eventTypes) async {
await sendCommand('subscribe_events', {
'event_types': eventTypes,
});
}
/// Disconnect from the remote server
Future<void> disconnect() async {
_isAuthenticated = false;
await _connection.disconnect();
onConnectionChanged?.call(false);
}
void _onConnectionStateChanged(WebRTCConnectionState state) {
DebugLogger().log('RemoteConnection: State changed to $state');
if (state == WebRTCConnectionState.disconnected ||
state == WebRTCConnectionState.failed) {
_isAuthenticated = false;
onConnectionChanged?.call(false);
}
}
void dispose() {
_messageSubscription?.cancel();
_eventController.close();
_connection.dispose();
}
}
+272
View File
@@ -0,0 +1,272 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:ensemble/services/debug_logger.dart';
/// Message types for the MA signaling protocol
class SignalingMessageType {
static const String ping = 'ping';
static const String pong = 'pong';
static const String connectRequest = 'connect-request';
static const String connected = 'connected';
static const String offer = 'offer';
static const String answer = 'answer';
static const String iceCandidate = 'ice-candidate';
static const String peerDisconnected = 'peer-disconnected';
static const String error = 'error';
}
/// ICE server configuration from signaling
class IceServer {
final List<String> urls;
final String? username;
final String? credential;
IceServer({
required this.urls,
this.username,
this.credential,
});
factory IceServer.fromJson(Map<String, dynamic> json) {
// Handle urls as either String or List<String>
final urlsRaw = json['urls'];
final List<String> urls;
if (urlsRaw is String) {
urls = [urlsRaw];
} else if (urlsRaw is List) {
urls = urlsRaw.cast<String>();
} else {
urls = [];
}
return IceServer(
urls: urls,
username: json['username'] as String?,
credential: json['credential'] as String?,
);
}
Map<String, dynamic> toWebRtcConfig() {
final config = <String, dynamic>{'urls': urls};
if (username != null) config['username'] = username;
if (credential != null) config['credential'] = credential;
return config;
}
}
/// Client for connecting to the MA signaling server
class SignalingClient {
static const String _signalingUrl = 'wss://signaling.music-assistant.io/ws';
WebSocketChannel? _channel;
StreamSubscription? _subscription;
Timer? _pingTimer;
String? _sessionId;
String? _remoteId;
List<IceServer> _iceServers = [];
bool _isConnected = false;
// Callbacks
Function(String sessionId, List<IceServer> iceServers)? onConnected;
Function(Map<String, dynamic> answer)? onAnswer;
Function(Map<String, dynamic> candidate)? onIceCandidate;
Function(String error)? onError;
Function()? onPeerDisconnected;
Function()? onDisconnected;
String? get sessionId => _sessionId;
List<IceServer> get iceServers => _iceServers;
bool get isConnected => _isConnected;
/// Connect to the signaling server and request connection to a remote MA server
Future<bool> connect(String remoteId) async {
DebugLogger().log('Signaling: Connecting to $_signalingUrl');
try {
// Normalize remote ID (remove dashes, uppercase)
final normalizedId = remoteId.replaceAll('-', '').toUpperCase();
_remoteId = normalizedId;
DebugLogger().log('Signaling: Remote ID: $normalizedId');
_channel = WebSocketChannel.connect(Uri.parse(_signalingUrl));
await _channel!.ready;
_subscription = _channel!.stream.listen(
_handleMessage,
onError: (error) {
DebugLogger().log('Signaling: WebSocket error: $error');
onError?.call(error.toString());
_cleanup();
},
onDone: () {
DebugLogger().log('Signaling: WebSocket closed');
onDisconnected?.call();
_cleanup();
},
);
// Start ping timer to keep connection alive
_startPingTimer();
// Send connect request
_send({
'type': SignalingMessageType.connectRequest,
'remoteId': normalizedId,
});
return true;
} catch (e) {
DebugLogger().log('Signaling: Failed to connect: $e');
onError?.call(e.toString());
return false;
}
}
/// Send WebRTC offer to the remote server
void sendOffer(Map<String, dynamic> offer) {
if (_sessionId == null || _remoteId == null) {
DebugLogger().log('Signaling: Cannot send offer - no session');
return;
}
_send({
'type': SignalingMessageType.offer,
'remoteId': _remoteId,
'sessionId': _sessionId,
'data': offer,
});
}
/// Send ICE candidate to the remote server
void sendIceCandidate(Map<String, dynamic> candidate) {
if (_sessionId == null || _remoteId == null) {
DebugLogger().log('Signaling: Cannot send ICE candidate - no session');
return;
}
_send({
'type': SignalingMessageType.iceCandidate,
'remoteId': _remoteId,
'sessionId': _sessionId,
'data': candidate,
});
}
/// Disconnect from the signaling server
void disconnect() {
DebugLogger().log('Signaling: Disconnecting');
_cleanup();
}
void _handleMessage(dynamic data) {
try {
final message = jsonDecode(data as String) as Map<String, dynamic>;
final type = message['type'] as String?;
DebugLogger().log('Signaling: Received message type: $type');
switch (type) {
case SignalingMessageType.ping:
// Server is pinging us - respond with pong to keep connection alive
_send({'type': SignalingMessageType.pong});
break;
case SignalingMessageType.pong:
// Ignore pong responses to our pings
break;
case SignalingMessageType.connected:
_handleConnected(message);
break;
case SignalingMessageType.answer:
_handleAnswer(message);
break;
case SignalingMessageType.iceCandidate:
_handleIceCandidate(message);
break;
case SignalingMessageType.peerDisconnected:
DebugLogger().log('Signaling: Peer disconnected');
onPeerDisconnected?.call();
break;
case SignalingMessageType.error:
final error = message['error'] as String? ?? 'Unknown error';
DebugLogger().log('Signaling: Error from server: $error');
onError?.call(error);
break;
default:
DebugLogger().log('Signaling: Unknown message type: $type');
}
} catch (e) {
DebugLogger().log('Signaling: Failed to handle message: $e');
}
}
void _handleConnected(Map<String, dynamic> message) {
_sessionId = message['sessionId'] as String?;
_isConnected = true;
// Parse ICE servers
final iceServersJson = message['iceServers'] as List<dynamic>?;
if (iceServersJson != null) {
_iceServers = iceServersJson
.map((s) => IceServer.fromJson(s as Map<String, dynamic>))
.toList();
}
DebugLogger().log('Signaling: Connected with session $_sessionId');
DebugLogger().log('Signaling: Received ${_iceServers.length} ICE servers');
onConnected?.call(_sessionId!, _iceServers);
}
void _handleAnswer(Map<String, dynamic> message) {
final answer = message['data'] as Map<String, dynamic>?;
if (answer != null) {
DebugLogger().log('Signaling: Received answer');
onAnswer?.call(answer);
}
}
void _handleIceCandidate(Map<String, dynamic> message) {
final candidate = message['data'] as Map<String, dynamic>?;
if (candidate != null) {
DebugLogger().log('Signaling: Received ICE candidate');
onIceCandidate?.call(candidate);
}
}
void _send(Map<String, dynamic> message) {
if (_channel == null) return;
final json = jsonEncode(message);
DebugLogger().log('Signaling: Sending ${message['type']}');
_channel!.sink.add(json);
}
void _startPingTimer() {
_pingTimer?.cancel();
// Send ping every 15 seconds to keep connection alive
_pingTimer = Timer.periodic(const Duration(seconds: 15), (_) {
_send({'type': SignalingMessageType.ping});
});
}
void _cleanup() {
_pingTimer?.cancel();
_pingTimer = null;
_subscription?.cancel();
_subscription = null;
_channel?.sink.close();
_channel = null;
_sessionId = null;
_remoteId = null;
_isConnected = false;
}
}
+506
View File
@@ -0,0 +1,506 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:uuid/uuid.dart';
import 'package:ensemble/services/remote/signaling_client.dart';
import 'package:ensemble/services/debug_logger.dart';
/// WebRTC connection state
enum WebRTCConnectionState {
disconnected,
connecting,
connected,
failed,
}
/// Manages a WebRTC peer connection for remote MA access
class WebRTCConnection {
RTCPeerConnection? _peerConnection;
RTCDataChannel? _dataChannel;
RTCDataChannel? _sendspinDataChannel;
final SignalingClient _signalingClient = SignalingClient();
final _uuid = const Uuid();
WebRTCConnectionState _state = WebRTCConnectionState.disconnected;
Completer<bool>? _connectionCompleter;
bool _serverHelloReceived = false;
Completer<void>? _serverHelloCompleter;
// Message handling for MA API
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
final _rawMessageController = StreamController<String>.broadcast();
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
// Sendspin message handling (text and binary)
final _sendspinTextController = StreamController<String>.broadcast();
final _sendspinBinaryController = StreamController<Uint8List>.broadcast();
// Server info
Map<String, dynamic>? serverInfo;
// Callbacks
Function(WebRTCConnectionState state)? onStateChanged;
Function(String error)? onError;
WebRTCConnectionState get state => _state;
Stream<Map<String, dynamic>> get messages => _messageController.stream;
Stream<String> get rawMessages => _rawMessageController.stream;
bool get isConnected => _state == WebRTCConnectionState.connected;
// Sendspin channel streams
Stream<String> get sendspinTextMessages => _sendspinTextController.stream;
Stream<Uint8List> get sendspinBinaryMessages => _sendspinBinaryController.stream;
bool get isSendspinConnected =>
_sendspinDataChannel?.state == RTCDataChannelState.RTCDataChannelOpen;
/// Connect to a remote MA server using the Remote Access ID
Future<bool> connect(String remoteId) async {
if (_state == WebRTCConnectionState.connecting) {
DebugLogger().log('WebRTC: Already connecting');
return false;
}
_setState(WebRTCConnectionState.connecting);
_connectionCompleter = Completer<bool>();
try {
// Set up signaling callbacks
_signalingClient.onConnected = _onSignalingConnected;
_signalingClient.onAnswer = _onAnswer;
_signalingClient.onIceCandidate = _onRemoteIceCandidate;
_signalingClient.onError = _onSignalingError;
_signalingClient.onPeerDisconnected = _onPeerDisconnected;
_signalingClient.onDisconnected = _onSignalingDisconnected;
// Connect to signaling server
final connected = await _signalingClient.connect(remoteId);
if (!connected) {
_setState(WebRTCConnectionState.failed);
return false;
}
// Wait for connection to complete (with timeout)
final result = await _connectionCompleter!.future.timeout(
const Duration(seconds: 30),
onTimeout: () {
DebugLogger().log('WebRTC: Connection timeout');
_onSignalingError('Connection timeout');
return false;
},
);
return result;
} catch (e) {
DebugLogger().log('WebRTC: Connection error: $e');
_setState(WebRTCConnectionState.failed);
onError?.call(e.toString());
return false;
}
}
/// Send a JSON-RPC request and wait for response
Future<Map<String, dynamic>> sendRequest(
String command, [
Map<String, dynamic>? params,
]) async {
if (_dataChannel == null || _dataChannel!.state != RTCDataChannelState.RTCDataChannelOpen) {
throw Exception('Data channel not open');
}
final messageId = _uuid.v4();
final request = {
'message_id': messageId,
'command': command,
if (params != null) 'args': params,
};
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[messageId] = completer;
final jsonStr = jsonEncode(request);
DebugLogger().log('WebRTC: Sending request: $jsonStr');
_dataChannel!.send(RTCDataChannelMessage(jsonStr));
// Timeout after 30 seconds
return completer.future.timeout(
const Duration(seconds: 30),
onTimeout: () {
_pendingRequests.remove(messageId);
throw TimeoutException('Request timed out: $command');
},
);
}
/// Send a raw message without waiting for response (for bridge passthrough)
void sendRaw(String message) {
if (_dataChannel == null) {
DebugLogger().log('WebRTC: Cannot send raw - data channel is null');
return;
}
final state = _dataChannel!.state;
if (state != RTCDataChannelState.RTCDataChannelOpen) {
DebugLogger().log('WebRTC: Cannot send raw - data channel state: $state');
return;
}
DebugLogger().log('WebRTC: Sending raw: ${message.substring(0, message.length > 100 ? 100 : message.length)}...');
_dataChannel!.send(RTCDataChannelMessage(message));
}
/// Check if the data channel is healthy
bool get isDataChannelHealthy {
if (_dataChannel == null) return false;
return _dataChannel!.state == RTCDataChannelState.RTCDataChannelOpen;
}
/// Get data channel state for debugging
String get dataChannelStateDebug {
if (_dataChannel == null) return 'null';
return _dataChannel!.state.toString();
}
/// Disconnect from the remote server
Future<void> disconnect() async {
DebugLogger().log('WebRTC: Disconnecting');
_signalingClient.disconnect();
await _dataChannel?.close();
await _sendspinDataChannel?.close();
await _peerConnection?.close();
_dataChannel = null;
_sendspinDataChannel = null;
_peerConnection = null;
// Complete all pending requests with an error before clearing
_completePendingRequestsWithError('WebRTC connection disconnected');
_serverHelloReceived = false;
_serverHelloCompleter = null;
serverInfo = null;
_setState(WebRTCConnectionState.disconnected);
}
/// Complete all pending requests with an error message
void _completePendingRequestsWithError(String reason) {
for (final entry in _pendingRequests.entries) {
if (!entry.value.isCompleted) {
entry.value.completeError(Exception(reason));
}
}
_pendingRequests.clear();
}
void _setState(WebRTCConnectionState newState) {
if (_state != newState) {
DebugLogger().log('WebRTC: State changed: $_state -> $newState');
_state = newState;
onStateChanged?.call(newState);
}
}
Future<void> _onSignalingConnected(String sessionId, List<IceServer> iceServers) async {
DebugLogger().log('WebRTC: Signaling connected, creating peer connection');
try {
// Configure Android audio to NOT manage focus since we only use data channels
// This prevents flutter_webrtc from interfering with system audio/speakers
await AndroidNativeAudioManagement.setAndroidAudioConfiguration(
AndroidAudioConfiguration(
manageAudioFocus: false,
),
);
DebugLogger().log('WebRTC: Android audio configured - manageAudioFocus: false');
// Create peer connection with ICE servers
final config = <String, dynamic>{
'iceServers': iceServers.map((s) => s.toWebRtcConfig()).toList(),
'sdpSemantics': 'unified-plan',
};
DebugLogger().log('WebRTC: ICE servers: ${config['iceServers']}');
_peerConnection = await createPeerConnection(config);
// Set up peer connection callbacks
_peerConnection!.onIceCandidate = _onLocalIceCandidate;
_peerConnection!.onIceConnectionState = _onIceConnectionState;
_peerConnection!.onConnectionState = _onConnectionState;
_peerConnection!.onDataChannel = _onDataChannel;
// Create data channel for MA API
final dataChannelInit = RTCDataChannelInit()
..ordered = true
..protocol = 'ma-api';
_dataChannel = await _peerConnection!.createDataChannel('ma-api', dataChannelInit);
_setupDataChannel(_dataChannel!);
// TEST: Sendspin channel DISABLED to test if multi-channel bug causes disconnects
// If stability improves, the flutter_webrtc multi-channel bug is confirmed
DebugLogger().log('WebRTC: ⚠️ TEST BUILD - Sendspin channel DISABLED (single channel mode)');
// final sendspinChannelInit = RTCDataChannelInit()
// ..ordered = true
// ..protocol = 'sendspin';
// _sendspinDataChannel = await _peerConnection!.createDataChannel('sendspin', sendspinChannelInit);
// _setupSendspinDataChannel(_sendspinDataChannel!);
// Create and send offer
final offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
DebugLogger().log('WebRTC: Sending offer');
_signalingClient.sendOffer({
'type': offer.type,
'sdp': offer.sdp,
});
} catch (e) {
DebugLogger().log('WebRTC: Failed to create peer connection: $e');
_onSignalingError(e.toString());
}
}
Future<void> _onAnswer(Map<String, dynamic> answer) async {
DebugLogger().log('WebRTC: Received answer');
try {
final description = RTCSessionDescription(
answer['sdp'] as String?,
answer['type'] as String?,
);
await _peerConnection?.setRemoteDescription(description);
} catch (e) {
DebugLogger().log('WebRTC: Failed to set remote description: $e');
_onSignalingError(e.toString());
}
}
void _onLocalIceCandidate(RTCIceCandidate candidate) {
DebugLogger().log('WebRTC: Local ICE candidate: ${candidate.candidate?.substring(0, 50)}...');
_signalingClient.sendIceCandidate({
'candidate': candidate.candidate,
'sdpMid': candidate.sdpMid,
'sdpMLineIndex': candidate.sdpMLineIndex,
});
}
Future<void> _onRemoteIceCandidate(Map<String, dynamic> candidateData) async {
DebugLogger().log('WebRTC: Remote ICE candidate received');
try {
final candidate = RTCIceCandidate(
candidateData['candidate'] as String?,
candidateData['sdpMid'] as String?,
candidateData['sdpMLineIndex'] as int?,
);
await _peerConnection?.addCandidate(candidate);
} catch (e) {
DebugLogger().log('WebRTC: Failed to add ICE candidate: $e');
}
}
void _onIceConnectionState(RTCIceConnectionState state) {
DebugLogger().log('WebRTC: ICE connection state: $state');
switch (state) {
case RTCIceConnectionState.RTCIceConnectionStateFailed:
_onSignalingError('ICE connection failed');
break;
case RTCIceConnectionState.RTCIceConnectionStateDisconnected:
DebugLogger().log('WebRTC: ICE disconnected, may recover');
break;
default:
break;
}
}
void _onConnectionState(RTCPeerConnectionState state) {
DebugLogger().log('WebRTC: Peer connection state: $state');
switch (state) {
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
// Connection established, but wait for data channel
break;
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
_onSignalingError('Peer connection failed');
break;
case RTCPeerConnectionState.RTCPeerConnectionStateClosed:
_setState(WebRTCConnectionState.disconnected);
break;
default:
break;
}
}
void _onDataChannel(RTCDataChannel channel) {
DebugLogger().log('WebRTC: Remote data channel received: ${channel.label}');
if (channel.label == 'ma-api') {
_dataChannel = channel;
_setupDataChannel(channel);
} else if (channel.label == 'sendspin') {
_sendspinDataChannel = channel;
_setupSendspinDataChannel(channel);
}
}
void _setupDataChannel(RTCDataChannel channel) {
channel.onDataChannelState = (state) {
DebugLogger().log('WebRTC: Data channel state: $state');
if (state == RTCDataChannelState.RTCDataChannelOpen) {
DebugLogger().log('WebRTC: Data channel open - waiting for server hello...');
// Don't complete yet - wait for server hello
_serverHelloCompleter = Completer<void>();
_serverHelloCompleter!.future.then((_) {
DebugLogger().log('WebRTC: Server hello received - connection complete!');
_setState(WebRTCConnectionState.connected);
_safeCompleteConnection(true);
});
} else if (state == RTCDataChannelState.RTCDataChannelClosed) {
DebugLogger().log('WebRTC: Data channel closed, completing pending requests with error');
_completePendingRequestsWithError('Data channel closed');
_setState(WebRTCConnectionState.disconnected);
}
};
channel.onMessage = _onDataChannelMessage;
}
void _onDataChannelMessage(RTCDataChannelMessage message) {
try {
DebugLogger().log('WebRTC: Raw message: ${message.text.substring(0, message.text.length > 200 ? 200 : message.text.length)}...');
final data = jsonDecode(message.text) as Map<String, dynamic>;
final messageId = data['message_id']?.toString();
DebugLogger().log('WebRTC: Parsed message keys: ${data.keys.toList()}, message_id: $messageId');
// Check for server hello message (has server_id but no message_id)
if (data.containsKey('server_id') && messageId == null && !_serverHelloReceived) {
DebugLogger().log('WebRTC: Server hello received - server_version: ${data['server_version']}');
serverInfo = data;
_serverHelloReceived = true;
_serverHelloCompleter?.complete();
// Don't forward server hello to rawMessages - bridge caches it separately
return;
}
// Check if this is a response to a pending request (for internal use like auth)
if (messageId != null && _pendingRequests.containsKey(messageId)) {
DebugLogger().log('WebRTC: Matched pending request $messageId');
_pendingRequests.remove(messageId)?.complete(data);
// Don't forward to rawMessages - this was an internal request
return;
}
// Forward all other messages to rawMessages stream (for bridge passthrough)
_rawMessageController.add(message.text);
// Also broadcast parsed event/notification for internal listeners
DebugLogger().log('WebRTC: No match for message_id=$messageId, pending: ${_pendingRequests.keys.toList()}');
_messageController.add(data);
} catch (e) {
DebugLogger().log('WebRTC: Failed to parse message: $e');
}
}
/// Setup Sendspin data channel for audio streaming
void _setupSendspinDataChannel(RTCDataChannel channel) {
channel.onDataChannelState = (state) {
DebugLogger().log('WebRTC: Sendspin data channel state: $state');
};
channel.onMessage = _onSendspinMessage;
}
/// Handle incoming Sendspin messages (both text and binary)
void _onSendspinMessage(RTCDataChannelMessage message) {
if (message.isBinary) {
// Binary audio data
final bytes = message.binary;
if (bytes != null && !_sendspinBinaryController.isClosed) {
_sendspinBinaryController.add(bytes);
}
} else {
// Text control message (JSON)
final text = message.text;
DebugLogger().log('WebRTC: Sendspin text message: ${text.substring(0, text.length > 100 ? 100 : text.length)}...');
if (!_sendspinTextController.isClosed) {
_sendspinTextController.add(text);
}
}
}
/// Send a text message to Sendspin channel (JSON control messages)
void sendSendspinText(String message) {
if (_sendspinDataChannel == null) {
DebugLogger().log('WebRTC: Cannot send sendspin text - channel is null');
return;
}
final state = _sendspinDataChannel!.state;
if (state != RTCDataChannelState.RTCDataChannelOpen) {
DebugLogger().log('WebRTC: Cannot send sendspin text - channel state: $state');
return;
}
_sendspinDataChannel!.send(RTCDataChannelMessage(message));
}
/// Send binary data to Sendspin channel (audio data from local player)
void sendSendspinBinary(Uint8List data) {
if (_sendspinDataChannel == null) {
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel is null');
return;
}
final state = _sendspinDataChannel!.state;
if (state != RTCDataChannelState.RTCDataChannelOpen) {
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel state: $state');
return;
}
_sendspinDataChannel!.send(RTCDataChannelMessage.fromBinary(data));
}
void _onSignalingError(String error) {
DebugLogger().log('WebRTC: Signaling error: $error');
_setState(WebRTCConnectionState.failed);
onError?.call(error);
_safeCompleteConnection(false);
}
void _onPeerDisconnected() {
DebugLogger().log('WebRTC: Peer disconnected, completing pending requests with error');
_completePendingRequestsWithError('Remote peer disconnected');
_setState(WebRTCConnectionState.disconnected);
onError?.call('Remote server disconnected');
}
void _onSignalingDisconnected() {
DebugLogger().log('WebRTC: Signaling disconnected');
// Only set failed if we were still connecting
if (_state == WebRTCConnectionState.connecting) {
_setState(WebRTCConnectionState.failed);
_safeCompleteConnection(false);
}
}
/// Safely complete the connection completer (only once)
void _safeCompleteConnection(bool success) {
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.complete(success);
}
}
void dispose() {
_messageController.close();
_rawMessageController.close();
_sendspinTextController.close();
_sendspinBinaryController.close();
disconnect();
}
}
+33 -3
View File
@@ -40,6 +40,10 @@ class SendspinService {
WebSocketChannel? _channel;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 10;
static const Duration _baseReconnectDelay = Duration(seconds: 3);
static const Duration _maxReconnectDelay = Duration(seconds: 60);
// Connection state
SendspinConnectionState _state = SendspinConnectionState.disconnected;
@@ -279,6 +283,7 @@ class SendspinService {
_logger.log('Sendspin: Connected and registered successfully');
_updateState(SendspinConnectionState.connected);
_resetReconnectAttempts();
_startHeartbeat();
return true;
@@ -559,6 +564,7 @@ class SendspinService {
'state': playerState,
'volume': _volume,
'muted': _isMuted,
'position': _position,
},
},
});
@@ -627,6 +633,7 @@ class SendspinService {
'state': 'synchronized',
'volume': _volume,
'muted': _isMuted,
'position': _position,
},
},
}, allowDuringHandshake: true);
@@ -638,12 +645,27 @@ class SendspinService {
_heartbeatTimer = null;
}
/// Schedule reconnection attempt
/// Schedule reconnection attempt with exponential backoff
void _scheduleReconnect() {
if (_isDisposed) return;
if (_reconnectAttempts >= _maxReconnectAttempts) {
_logger.log('Sendspin: Max reconnection attempts reached ($_maxReconnectAttempts), giving up');
_updateState(SendspinConnectionState.disconnected);
return;
}
_reconnectAttempts++;
// Calculate exponential backoff delay: 3s, 6s, 12s, 24s, 48s, then cap at 60s
final delaySeconds = (_baseReconnectDelay.inSeconds * (1 << (_reconnectAttempts - 1)))
.clamp(0, _maxReconnectDelay.inSeconds);
final delay = Duration(seconds: delaySeconds);
_logger.log('Sendspin: Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 5), () {
_reconnectTimer = Timer(delay, () {
if (!_isDisposed && _state != SendspinConnectionState.connected) {
_logger.log('Sendspin: Attempting reconnection...');
if (_connectedUrl != null) {
@@ -653,11 +675,19 @@ class SendspinService {
});
}
/// Reset reconnection attempts counter (call on successful connection)
void _resetReconnectAttempts() {
_reconnectAttempts = 0;
}
/// Update connection state
void _updateState(SendspinConnectionState newState) {
if (_state != newState) {
_state = newState;
_stateController.add(newState);
// Don't add events if controller is closed (disposed)
if (!_isDisposed) {
_stateController.add(newState);
}
}
}
+31 -12
View File
@@ -73,6 +73,7 @@ class SettingsService {
static const String _keyServerUrl = 'server_url';
static const String _keyAuthServerUrl = 'auth_server_url';
static const String _keyWebSocketPort = 'websocket_port';
static const String _keyRemoteAccessId = 'remote_access_id'; // MA Remote Access ID for WebRTC
static const String _keyAuthToken = 'auth_token';
static const String _keyMaAuthToken = 'ma_auth_token'; // Music Assistant native auth token
static const String _keyAuthCredentials = 'auth_credentials'; // NEW: Serialized auth strategy credentials
@@ -90,7 +91,9 @@ class SettingsService {
static const String _keyLocalPlayerName = 'local_player_name';
static const String _keyOwnerName = 'owner_name';
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
static const String _keyPreferLocalPlayer = 'prefer_local_player';
static const String _keySmartSortPlayers = 'smart_sort_players';
static const String _keyDisableAutoSwitch = 'disable_auto_switch';
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
@@ -150,7 +153,6 @@ class SettingsService {
static const String _keyLibrarySeriesSortOrder = 'library_series_sort_order'; // 'alpha', 'alpha_desc', 'books'
static const String _keyLibraryRadioSortOrder = 'library_radio_sort_order'; // 'alpha', 'alpha_desc'
static const String _keyLibraryPodcastsSortOrder = 'library_podcasts_sort_order'; // 'alpha', 'alpha_desc'
static const String _keyPodcastEpisodesSortOrder = 'podcast_episodes_sort_order'; // 'newest', 'oldest', 'alpha', 'duration'
// Audiobookshelf Direct Integration Settings
static const String _keyAbsServerUrl = 'abs_server_url';
@@ -192,6 +194,26 @@ class SettingsService {
await prefs.remove(_keyServerUrl);
}
// Remote Access ID for WebRTC-based remote connections
static Future<String?> getRemoteAccessId() async {
final prefs = await _getPrefs();
return prefs.getString(_keyRemoteAccessId);
}
static Future<void> setRemoteAccessId(String? id) async {
final prefs = await _getPrefs();
if (id == null || id.isEmpty) {
await prefs.remove(_keyRemoteAccessId);
} else {
await prefs.setString(_keyRemoteAccessId, id);
}
}
static Future<void> clearRemoteAccessId() async {
final prefs = await _getPrefs();
await prefs.remove(_keyRemoteAccessId);
}
// Get authentication server URL (returns null if not set, meaning use server URL)
static Future<String?> getAuthServerUrl() async {
final prefs = await _getPrefs();
@@ -353,10 +375,18 @@ class SettingsService {
static Future<void> setLastSelectedPlayerId(String? playerId) =>
_setString(_keyLastSelectedPlayerId, playerId, removeIfEmpty: true);
// Prefer Local Player - always select local player first when available
static Future<bool> getPreferLocalPlayer() => _getBool(_keyPreferLocalPlayer, defaultValue: false);
static Future<void> setPreferLocalPlayer(bool prefer) => _setBool(_keyPreferLocalPlayer, prefer);
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
// Disable Auto-Switch - prevents automatic switching to playing players
static Future<bool> getDisableAutoSwitch() => _getBool(_keyDisableAutoSwitch, defaultValue: false);
static Future<void> setDisableAutoSwitch(bool disable) => _setBool(_keyDisableAutoSwitch, disable);
// Helper to create player name with possessive apostrophe
// Automatically detects Phone vs Tablet based on screen size
static String _makePlayerName(String ownerName) {
@@ -683,17 +713,6 @@ class SettingsService {
await prefs.setString(_keyLibraryPodcastsSortOrder, order);
}
// Podcast Episode Sort Order (within podcast detail screen)
static Future<String> getPodcastEpisodesSortOrder() async {
final prefs = await _getPrefs();
return prefs.getString(_keyPodcastEpisodesSortOrder) ?? 'newest';
}
static Future<void> setPodcastEpisodesSortOrder(String order) async {
final prefs = await _getPrefs();
await prefs.setString(_keyPodcastEpisodesSortOrder, order);
}
// Audiobookshelf Settings
static Future<String?> getAbsServerUrl() async {
final prefs = await _getPrefs();
+83 -104
View File
@@ -242,24 +242,44 @@ class SyncService with ChangeNotifier {
final playlistMap = <String, Playlist>{};
// If specific providers are requested, sync each separately for accurate source tracking
// Use smaller batches in remote mode to avoid WebRTC message size issues
final limit = api.isRemoteMode ? 100 : 1000;
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
_logger.log('🔒 Per-provider sync for ${providerInstanceIds.length} providers');
_logger.log('🔒 Per-provider sync for ${providerInstanceIds.length} providers (limit: $limit, remote: ${api.isRemoteMode})');
for (final providerId in providerInstanceIds) {
_logger.log(' 📡 Syncing provider: $providerId');
// Fetch from this specific provider
final results = await Future.wait([
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
api.getAudiobooks(limit: 1000, providerInstanceIds: [providerId]),
api.getPlaylists(limit: 1000, providerInstanceIds: [providerId]),
]);
List<Album> albums;
List<Artist> artists;
List<Audiobook> audiobooks;
List<Playlist> playlists;
final albums = results[0] as List<Album>;
final artists = results[1] as List<Artist>;
final audiobooks = results[2] as List<Audiobook>;
final playlists = results[3] as List<Playlist>;
// In remote mode, do sequential requests to avoid overwhelming WebRTC
if (api.isRemoteMode) {
albums = await api.getAlbums(limit: limit, providerInstanceIds: [providerId]);
await Future.delayed(const Duration(milliseconds: 300));
artists = await api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]);
await Future.delayed(const Duration(milliseconds: 300));
audiobooks = await api.getAudiobooks(limit: limit, providerInstanceIds: [providerId]);
await Future.delayed(const Duration(milliseconds: 300));
playlists = await api.getPlaylists(limit: limit, providerInstanceIds: [providerId]);
await Future.delayed(const Duration(milliseconds: 300));
} else {
// Local mode: parallel requests for speed
final results = await Future.wait([
api.getAlbums(limit: limit, providerInstanceIds: [providerId]),
api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums, providerInstanceIds: [providerId]),
api.getAudiobooks(limit: limit, providerInstanceIds: [providerId]),
api.getPlaylists(limit: limit, providerInstanceIds: [providerId]),
]);
albums = results[0] as List<Album>;
artists = results[1] as List<Artist>;
audiobooks = results[2] as List<Audiobook>;
playlists = results[3] as List<Playlist>;
}
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
@@ -295,26 +315,59 @@ class SyncService with ChangeNotifier {
}
} else {
// No provider filter - fetch all at once (faster, but no source tracking)
_logger.log('📡 Fetching from all providers (no source tracking)');
_logger.log('📡 Fetching from all providers (no source tracking, limit: $limit)');
final results = await Future.wait([
api.getAlbums(limit: 1000),
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: 1000),
api.getPlaylists(limit: 1000),
]);
// In remote mode, do sequential requests to avoid overwhelming WebRTC
if (api.isRemoteMode) {
_logger.log('🌐 Remote mode: using sequential requests with delays');
for (final album in results[0] as List<Album>) {
albumMap[album.itemId] = album;
}
for (final artist in results[1] as List<Artist>) {
artistMap[artist.itemId] = artist;
}
for (final audiobook in results[2] as List<Audiobook>) {
audiobookMap[audiobook.itemId] = audiobook;
}
for (final playlist in results[3] as List<Playlist>) {
playlistMap[playlist.itemId] = playlist;
final albums = await api.getAlbums(limit: limit);
for (final album in albums) {
albumMap[album.itemId] = album;
}
_logger.log(' 📥 Got ${albums.length} albums');
await Future.delayed(const Duration(milliseconds: 500));
final artists = await api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums);
for (final artist in artists) {
artistMap[artist.itemId] = artist;
}
_logger.log(' 📥 Got ${artists.length} artists');
await Future.delayed(const Duration(milliseconds: 500));
final audiobooks = await api.getAudiobooks(limit: limit);
for (final audiobook in audiobooks) {
audiobookMap[audiobook.itemId] = audiobook;
}
_logger.log(' 📥 Got ${audiobooks.length} audiobooks');
await Future.delayed(const Duration(milliseconds: 500));
final playlists = await api.getPlaylists(limit: limit);
for (final playlist in playlists) {
playlistMap[playlist.itemId] = playlist;
}
_logger.log(' 📥 Got ${playlists.length} playlists');
} else {
// Local mode: parallel requests for speed
final results = await Future.wait([
api.getAlbums(limit: limit),
api.getArtists(limit: limit, albumArtistsOnly: showOnlyArtistsWithAlbums),
api.getAudiobooks(limit: limit),
api.getPlaylists(limit: limit),
]);
for (final album in results[0] as List<Album>) {
albumMap[album.itemId] = album;
}
for (final artist in results[1] as List<Artist>) {
artistMap[artist.itemId] = artist;
}
for (final audiobook in results[2] as List<Audiobook>) {
audiobookMap[audiobook.itemId] = audiobook;
}
for (final playlist in results[3] as List<Playlist>) {
playlistMap[playlist.itemId] = playlist;
}
}
}
@@ -596,78 +649,4 @@ class SyncService with ChangeNotifier {
_cachedPlaylists = playlists;
notifyListeners();
}
/// Remove an item from in-memory cache by library ID
/// Called when an item is removed from library to keep SyncService in sync
void removeFromCacheByLibraryId(String mediaType, int libraryItemId) {
final libraryIdStr = libraryItemId.toString();
bool updated = false;
// Helper to check if item matches the library ID being removed
bool matchesLibraryId(String? provider, String? itemId, List<ProviderMapping>? mappings) {
if (provider == 'library' && itemId == libraryIdStr) return true;
if (mappings != null) {
for (final m in mappings) {
if ((m.providerInstance == 'library' || m.providerDomain == 'library') &&
m.itemId == libraryIdStr) {
return true;
}
}
}
return false;
}
switch (mediaType) {
case 'album':
final before = _cachedAlbums.length;
_cachedAlbums = _cachedAlbums.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _cachedAlbums.length != before;
// Also remove from source provider tracking
if (updated) {
_albumSourceProviders.removeWhere((key, _) =>
_cachedAlbums.every((a) => a.itemId != key));
}
break;
case 'artist':
final before = _cachedArtists.length;
_cachedArtists = _cachedArtists.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _cachedArtists.length != before;
if (updated) {
_artistSourceProviders.removeWhere((key, _) =>
_cachedArtists.every((a) => a.itemId != key));
}
break;
case 'audiobook':
final before = _cachedAudiobooks.length;
_cachedAudiobooks = _cachedAudiobooks.where((a) =>
!matchesLibraryId(a.provider, a.itemId, a.providerMappings)
).toList();
updated = _cachedAudiobooks.length != before;
if (updated) {
_audiobookSourceProviders.removeWhere((key, _) =>
_cachedAudiobooks.every((a) => a.itemId != key));
}
break;
case 'playlist':
final before = _cachedPlaylists.length;
_cachedPlaylists = _cachedPlaylists.where((p) =>
!matchesLibraryId(p.provider, p.itemId, p.providerMappings)
).toList();
updated = _cachedPlaylists.length != before;
if (updated) {
_playlistSourceProviders.removeWhere((key, _) =>
_cachedPlaylists.every((p) => p.itemId != key));
}
break;
}
if (updated) {
_logger.log('🗑️ Removed $mediaType with libraryId=$libraryItemId from SyncService cache');
notifyListeners();
}
}
}
+49 -470
View File
@@ -1,25 +1,9 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_color_utilities/material_color_utilities.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:image/image.dart' as img;
import 'package:http/http.dart' as http;
import '../services/debug_logger.dart';
final _logger = DebugLogger();
/// Minimum contrast ratio for UI components (WCAG 2.1 Level AA)
const double _minContrastRatio = 3.0;
/// Minimum chroma for primary colors to ensure they're visually distinct
/// Material Design 3 uses 48 for vibrant, but we use 36 as a balance
const double _minPrimaryChroma = 36.0;
/// Minimum chroma for surface/container colors (lower is fine for backgrounds)
const double _minSurfaceChroma = 6.0;
/// Extracted adaptive colors from album art
class AdaptiveColors {
final Color primary; // Main accent color (vibrant)
@@ -42,398 +26,15 @@ class AdaptiveColors {
);
}
/// Data class to pass color extraction results from isolate
/// (Color objects can't be passed directly between isolates)
class _ExtractedColors {
final int vibrantColor;
final int lightVibrantColor;
final int dominantColor;
final int darkMutedColor;
final int mutedColor;
final int lightMutedColor;
const _ExtractedColors({
required this.vibrantColor,
required this.lightVibrantColor,
required this.dominantColor,
required this.darkMutedColor,
required this.mutedColor,
required this.lightMutedColor,
});
}
/// Calculate WCAG 2.1 contrast ratio between two colors
double _contrastRatio(Color c1, Color c2) {
final l1 = c1.computeLuminance();
final l2 = c2.computeLuminance();
final lighter = math.max(l1, l2);
final darker = math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/// Helper to get contrasting text color based on WCAG contrast ratio
/// Helper to get contrasting text color based on background luminance
Color getContrastingTextColor(Color backgroundColor) {
// Calculate contrast with both black and white, pick the one with better contrast
final contrastWithWhite = _contrastRatio(backgroundColor, Colors.white);
final contrastWithBlack = _contrastRatio(backgroundColor, Colors.black);
return contrastWithWhite >= contrastWithBlack ? Colors.white : Colors.black;
}
/// Adjust a color's tone using HCT to ensure minimum contrast against a background
/// Uses binary search to find the MINIMUM tone adjustment needed (preserves color identity)
Color _ensureContrastHct(Color foreground, Color background, {double minRatio = _minContrastRatio}) {
final fgHct = Hct.fromInt(foreground.value);
final bgHct = Hct.fromInt(background.value);
// Ensure foreground has enough chroma to be visually distinct
final chroma = math.max(fgHct.chroma, _minPrimaryChroma);
final currentRatio = _contrastRatio(foreground, background);
if (currentRatio >= minRatio) {
// Already has sufficient contrast, just ensure chroma
if (fgHct.chroma < _minPrimaryChroma) {
return Color(Hct.from(fgHct.hue, chroma, fgHct.tone).toInt());
}
return foreground;
}
// For dark backgrounds (tone < 50), we need lighter foreground
// For light backgrounds (tone >= 50), we need darker foreground
final bgIsDark = bgHct.tone < 50;
// Binary search for the minimum tone that achieves target contrast
double lo, hi;
if (bgIsDark) {
// Search upward from current tone toward white
lo = fgHct.tone;
hi = 100.0;
} else {
// Search downward from current tone toward black
lo = 0.0;
hi = fgHct.tone;
}
double bestTone = bgIsDark ? hi : lo; // Fallback to extreme if needed
for (int i = 0; i < 20; i++) {
final mid = (lo + hi) / 2;
final testColor = Hct.from(fgHct.hue, chroma, mid);
final testRatio = _contrastRatio(Color(testColor.toInt()), background);
if (bgIsDark) {
// We want lighter - if mid works, try lower (closer to original)
if (testRatio >= minRatio) {
bestTone = mid;
hi = mid; // Try to find a lower tone that still works
} else {
lo = mid; // Need to go lighter
}
} else {
// We want darker - if mid works, try higher (closer to original)
if (testRatio >= minRatio) {
bestTone = mid;
lo = mid; // Try to find a higher tone that still works
} else {
hi = mid; // Need to go darker
}
}
if (hi - lo < 0.5) break; // Precision reached
}
return Color(Hct.from(fgHct.hue, chroma, bestTone).toInt());
}
/// Ensure a color has minimum chroma (colorfulness) for primary use
Color _ensureMinChroma(Color color) {
final hct = Hct.fromInt(color.value);
if (hct.chroma >= _minPrimaryChroma) {
return color;
}
final adjusted = Hct.from(hct.hue, _minPrimaryChroma, hct.tone);
return Color(adjusted.toInt());
}
/// Top-level function for isolate-based color extraction
/// Must be top-level or static to work with compute()
_ExtractedColors? _extractColorsInIsolate(Uint8List imageBytes) {
try {
// Decode image using pure Dart image package
final image = img.decodeImage(imageBytes);
if (image == null) return null;
// Resize for faster processing (max 100x100)
final resized = img.copyResize(
image,
width: image.width > 100 ? 100 : image.width,
height: image.height > 100 ? 100 : image.height,
interpolation: img.Interpolation.average,
);
// Extract color histogram
final colorCounts = <int, int>{};
for (int y = 0; y < resized.height; y++) {
for (int x = 0; x < resized.width; x++) {
final pixel = resized.getPixel(x, y);
// Quantize to reduce color space (shift off lower 4 bits)
final r = (pixel.r.toInt() >> 4) << 4;
final g = (pixel.g.toInt() >> 4) << 4;
final b = (pixel.b.toInt() >> 4) << 4;
final quantized = (0xFF << 24) | (r << 16) | (g << 8) | b;
colorCounts[quantized] = (colorCounts[quantized] ?? 0) + 1;
}
}
// Sort colors by frequency
final sortedColors = colorCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
if (sortedColors.isEmpty) return null;
// Categorize colors by HSL properties
int? vibrant, lightVibrant, dominant, darkMuted, muted, lightMuted;
for (final entry in sortedColors.take(32)) {
final color = entry.key;
final r = (color >> 16) & 0xFF;
final g = (color >> 8) & 0xFF;
final b = color & 0xFF;
// Calculate HSL manually
final rf = r / 255.0;
final gf = g / 255.0;
final bf = b / 255.0;
final maxC = [rf, gf, bf].reduce((a, b) => a > b ? a : b);
final minC = [rf, gf, bf].reduce((a, b) => a < b ? a : b);
final lightness = (maxC + minC) / 2;
final saturation = maxC == minC ? 0.0 :
(lightness > 0.5
? (maxC - minC) / (2 - maxC - minC)
: (maxC - minC) / (maxC + minC));
// Categorize based on saturation and lightness
if (saturation > 0.5 && lightness > 0.3 && lightness < 0.7) {
vibrant ??= color;
}
if (saturation > 0.4 && lightness > 0.6) {
lightVibrant ??= color;
}
if (saturation < 0.4 && lightness < 0.3) {
darkMuted ??= color;
}
if (saturation < 0.4 && lightness > 0.3 && lightness < 0.7) {
muted ??= color;
}
if (saturation < 0.4 && lightness > 0.7) {
lightMuted ??= color;
}
// First color is dominant
dominant ??= color;
}
// Fallback to dominant for any missing colors
final defaultColor = dominant ?? sortedColors.first.key;
return _ExtractedColors(
vibrantColor: vibrant ?? defaultColor,
lightVibrantColor: lightVibrant ?? vibrant ?? defaultColor,
dominantColor: dominant ?? defaultColor,
darkMutedColor: darkMuted ?? defaultColor,
mutedColor: muted ?? defaultColor,
lightMutedColor: lightMuted ?? muted ?? defaultColor,
);
} catch (e) {
return null;
}
// Use relative luminance to determine if we need light or dark text
// Standard threshold is 0.179 based on WCAG guidelines
return backgroundColor.computeLuminance() > 0.4 ? Colors.black : Colors.white;
}
class PaletteHelper {
/// Cache for downloaded image bytes to avoid re-downloading
static final Map<String, Uint8List> _imageCache = {};
static const int _maxCacheSize = 20;
/// Cache for extracted color schemes to avoid re-processing
static final Map<String, (ColorScheme, ColorScheme)> _colorSchemeCache = {};
static const int _maxColorSchemeCacheSize = 30;
/// Extract color schemes from a URL using isolate-based processing
/// This is the preferred method as it doesn't block the main thread
/// Results are cached for instant retrieval on subsequent calls
static Future<(ColorScheme, ColorScheme)?> extractColorSchemesFromUrl(String imageUrl) async {
try {
// Check color scheme cache first for instant return
final cachedSchemes = _colorSchemeCache[imageUrl];
if (cachedSchemes != null) {
return cachedSchemes;
}
// Check image bytes cache
Uint8List? imageBytes = _imageCache[imageUrl];
if (imageBytes == null) {
// Download image bytes
final response = await http.get(Uri.parse(imageUrl));
if (response.statusCode != 200) {
_logger.warning('Failed to download image: ${response.statusCode}', context: 'Palette');
return null;
}
imageBytes = response.bodyBytes;
// Cache the bytes
if (_imageCache.length >= _maxCacheSize) {
_imageCache.remove(_imageCache.keys.first);
}
_imageCache[imageUrl] = imageBytes;
}
// Extract colors in isolate
final extractedColors = await compute(_extractColorsInIsolate, imageBytes);
if (extractedColors == null) return null;
// Convert to ColorSchemes on main thread
final colorSchemes = _buildColorSchemes(extractedColors);
// Cache the color schemes for instant access next time
if (_colorSchemeCache.length >= _maxColorSchemeCacheSize) {
_colorSchemeCache.remove(_colorSchemeCache.keys.first);
}
_colorSchemeCache[imageUrl] = colorSchemes;
return colorSchemes;
} catch (e) {
_logger.warning('Failed to extract colors from URL: $e', context: 'Palette');
return null;
}
}
/// Build ColorSchemes from extracted color data using HCT color space
static (ColorScheme, ColorScheme) _buildColorSchemes(_ExtractedColors colors) {
// Convert int colors to Color objects
final vibrant = Color(colors.vibrantColor);
final lightVibrant = Color(colors.lightVibrantColor);
final dominant = Color(colors.dominantColor);
final darkMuted = Color(colors.darkMutedColor);
final muted = Color(colors.mutedColor);
final lightMuted = Color(colors.lightMutedColor);
// Get primary color - prefer vibrant, then lightVibrant, then dominant
Color primary = vibrant;
final vibrantHct = Hct.fromInt(vibrant.value);
if (vibrantHct.tone < 20 || vibrantHct.chroma < 8) {
primary = lightVibrant;
}
final primaryHct = Hct.fromInt(primary.value);
if (primaryHct.tone < 20 || primaryHct.chroma < 8) {
primary = dominant;
}
// Use HCT for perceptually accurate color adjustment
// Ensure minimum chroma (colorfulness) so colors aren't muddy
Color adjustedPrimary = _ensureMinChroma(primary);
// Build dark mode colors using HCT
final darkMutedHct = Hct.fromInt(darkMuted.value);
final darkSurface = Color(Hct.from(
darkMutedHct.hue,
math.min(darkMutedHct.chroma, _minSurfaceChroma), // Low chroma for surface
math.max(darkMutedHct.tone * 0.3, 5.0).clamp(5.0, 15.0), // Very dark
).toInt());
final darkMiniPlayer = Color(Hct.from(
darkMutedHct.hue,
(darkMutedHct.chroma * 1.2).clamp(4, 16),
30, // Fixed tone for consistency
).toInt());
// Build light mode colors using HCT
final lightMutedHct = Hct.fromInt(lightMuted.value);
final lightSurface = Color(Hct.from(
lightMutedHct.hue,
math.min(lightMutedHct.chroma, _minSurfaceChroma),
95, // Very light
).toInt());
final lightMiniPlayer = Color(Hct.from(
lightMutedHct.hue,
(lightMutedHct.chroma * 1.2).clamp(4, 16),
75,
).toInt());
// Generate secondary container colors using HCT
// Dark secondary container at tone 25 for tonal buttons
final primaryHctFinal = Hct.fromInt(adjustedPrimary.value);
final darkSecondaryContainer = Color(Hct.from(
primaryHctFinal.hue,
(primaryHctFinal.chroma * 0.4).clamp(4, 16),
25, // Fixed tone for dark mode tonal buttons
).toInt());
final lightSecondaryContainer = Color(Hct.from(
primaryHctFinal.hue,
(primaryHctFinal.chroma * 0.3).clamp(4, 12),
90, // Fixed tone for light mode tonal buttons
).toInt());
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
// This guarantees highlighted icons are visible on tonal buttons
final darkAdjustedPrimary = _ensureContrastHct(
adjustedPrimary,
darkSecondaryContainer,
minRatio: _minContrastRatio,
);
final lightAdjustedPrimary = _ensureContrastHct(
adjustedPrimary,
lightSecondaryContainer,
minRatio: _minContrastRatio,
);
// Determine contrasting text colors
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
final lightOnMiniPlayer = getContrastingTextColor(lightMiniPlayer);
final darkOnMiniPlayer = getContrastingTextColor(darkMiniPlayer);
final lightScheme = ColorScheme(
brightness: Brightness.light,
primary: lightAdjustedPrimary,
onPrimary: lightOnPrimary,
secondary: lightAdjustedPrimary.withOpacity(0.8),
onSecondary: lightOnPrimary,
secondaryContainer: lightSecondaryContainer,
onSecondaryContainer: Colors.black87,
error: Colors.red,
onError: Colors.white,
surface: lightSurface,
onSurface: Colors.black87,
primaryContainer: lightMiniPlayer,
onPrimaryContainer: lightOnMiniPlayer,
);
final darkScheme = ColorScheme(
brightness: Brightness.dark,
primary: darkAdjustedPrimary,
onPrimary: darkOnPrimary,
secondary: darkAdjustedPrimary.withOpacity(0.8),
onSecondary: darkOnPrimary,
secondaryContainer: darkSecondaryContainer,
onSecondaryContainer: Colors.white,
error: Colors.redAccent,
onError: Colors.black,
surface: darkSurface,
onSurface: Colors.white,
primaryContainer: darkMiniPlayer,
onPrimaryContainer: darkOnMiniPlayer,
);
return (lightScheme, darkScheme);
}
/// Clear all caches (image bytes and color schemes)
static void clearCache() {
_imageCache.clear();
_colorSchemeCache.clear();
}
/// Extract a color palette from an image with higher color count for better variety
/// NOTE: This runs on the main thread. Prefer extractColorSchemesFromUrl for better performance.
static Future<PaletteGenerator?> extractPalette(ImageProvider imageProvider) async {
try {
final palette = await PaletteGenerator.fromImageProvider(
@@ -447,7 +48,7 @@ class PaletteHelper {
}
}
/// Extract actual colors from the palette using HCT color space
/// Extract actual colors from the palette (not seed-based)
static AdaptiveColors? extractAdaptiveColors(PaletteGenerator? palette, {required bool isDark}) {
if (palette == null) return null;
@@ -457,9 +58,22 @@ class PaletteHelper {
palette.dominantColor?.color ??
const Color(0xFF604CEC);
// Use HCT for perceptually accurate color adjustment
// Ensure minimum chroma (colorfulness) so colors aren't muddy
Color adjustedPrimary = _ensureMinChroma(primary);
// Ensure primary has enough saturation to be visually distinct
// AND enough lightness for white text to be readable on it
final HSLColor hslPrimary = HSLColor.fromColor(primary);
Color adjustedPrimary = primary;
// Adjust saturation if too low
if (hslPrimary.saturation < 0.3) {
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
}
// Ensure minimum lightness for contrast with white text
// If primary is too dark (luminance < 0.15), lighten it
if (adjustedPrimary.computeLuminance() < 0.15) {
final hsl = HSLColor.fromColor(adjustedPrimary);
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
}
if (isDark) {
// Dark mode: use dark muted colors for background
@@ -467,19 +81,18 @@ class PaletteHelper {
palette.mutedColor?.color ??
const Color(0xFF121212);
// Build surface colors using HCT
final surfaceHct = Hct.fromInt(surfaceBase.value);
final Color surface = Color(Hct.from(
surfaceHct.hue,
math.min(surfaceHct.chroma, _minSurfaceChroma),
math.max(surfaceHct.tone * 0.3, 5.0).clamp(5.0, 15.0),
).toInt());
// Darken the surface color significantly for the expanded background
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
final Color surface = hslSurface
.withLightness((hslSurface.lightness * 0.3).clamp(0.05, 0.15))
.toColor();
final Color miniPlayer = Color(Hct.from(
surfaceHct.hue,
(surfaceHct.chroma * 1.2).clamp(4, 16),
30,
).toInt());
// Mini player should be medium brightness - noticeably tinted but readable
// Use the muted color but at medium lightness (0.25-0.35 range)
final Color miniPlayer = hslSurface
.withLightness(0.3.clamp(0.25, 0.38))
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
.toColor();
return AdaptiveColors(
primary: adjustedPrimary,
@@ -493,18 +106,16 @@ class PaletteHelper {
palette.mutedColor?.color ??
Colors.white;
final surfaceHct = Hct.fromInt(surfaceBase.value);
final Color surface = Color(Hct.from(
surfaceHct.hue,
math.min(surfaceHct.chroma, _minSurfaceChroma),
95,
).toInt());
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
final Color surface = hslSurface
.withLightness((hslSurface.lightness).clamp(0.92, 0.98))
.toColor();
final Color miniPlayer = Color(Hct.from(
surfaceHct.hue,
(surfaceHct.chroma * 1.2).clamp(4, 16),
75,
).toInt());
// Mini player in light mode - medium tinted
final Color miniPlayer = hslSurface
.withLightness(0.75.clamp(0.65, 0.8))
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
.toColor();
return AdaptiveColors(
primary: adjustedPrimary,
@@ -515,7 +126,7 @@ class PaletteHelper {
}
}
/// Generate color schemes from a palette using HCT color space
/// Generate color schemes from a palette (kept for backward compatibility)
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
if (palette == null) return null;
@@ -524,47 +135,18 @@ class PaletteHelper {
if (lightColors == null || darkColors == null) return null;
// Generate secondary container colors using HCT
final lightPrimaryHct = Hct.fromInt(lightColors.primary.value);
final lightSecondaryContainer = Color(Hct.from(
lightPrimaryHct.hue,
(lightPrimaryHct.chroma * 0.3).clamp(4, 12),
90, // Fixed tone for light mode tonal buttons
).toInt());
final darkPrimaryHct = Hct.fromInt(darkColors.primary.value);
final darkSecondaryContainer = Color(Hct.from(
darkPrimaryHct.hue,
(darkPrimaryHct.chroma * 0.4).clamp(4, 16),
25, // Fixed tone for dark mode tonal buttons
).toInt());
// CRITICAL: Ensure primary has sufficient contrast against secondaryContainer
final lightAdjustedPrimary = _ensureContrastHct(
lightColors.primary,
lightSecondaryContainer,
minRatio: _minContrastRatio,
);
final darkAdjustedPrimary = _ensureContrastHct(
darkColors.primary,
darkSecondaryContainer,
minRatio: _minContrastRatio,
);
// Determine contrasting text colors
final lightOnPrimary = getContrastingTextColor(lightAdjustedPrimary);
final darkOnPrimary = getContrastingTextColor(darkAdjustedPrimary);
// Determine contrasting text colors based on primary color luminance
final lightOnPrimary = getContrastingTextColor(lightColors.primary);
final darkOnPrimary = getContrastingTextColor(darkColors.primary);
final lightOnMiniPlayer = getContrastingTextColor(lightColors.miniPlayer);
final darkOnMiniPlayer = getContrastingTextColor(darkColors.miniPlayer);
final lightScheme = ColorScheme(
brightness: Brightness.light,
primary: lightAdjustedPrimary,
primary: lightColors.primary,
onPrimary: lightOnPrimary,
secondary: lightAdjustedPrimary.withOpacity(0.8),
secondary: lightColors.primary.withOpacity(0.8),
onSecondary: lightOnPrimary,
secondaryContainer: lightSecondaryContainer,
onSecondaryContainer: Colors.black87,
error: Colors.red,
onError: Colors.white,
surface: lightColors.surface,
@@ -575,12 +157,10 @@ class PaletteHelper {
final darkScheme = ColorScheme(
brightness: Brightness.dark,
primary: darkAdjustedPrimary,
primary: darkColors.primary,
onPrimary: darkOnPrimary,
secondary: darkAdjustedPrimary.withOpacity(0.8),
secondary: darkColors.primary.withOpacity(0.8),
onSecondary: darkOnPrimary,
secondaryContainer: darkSecondaryContainer,
onSecondaryContainer: Colors.white,
error: Colors.redAccent,
onError: Colors.black,
surface: darkColors.surface,
@@ -593,7 +173,6 @@ class PaletteHelper {
}
/// Extract color schemes from an image provider in one call
/// NOTE: This runs on the main thread. Prefer extractColorSchemesFromUrl for better performance.
static Future<(ColorScheme, ColorScheme)?> extractColorSchemes(ImageProvider imageProvider) async {
final palette = await extractPalette(imageProvider);
return generateColorSchemes(palette);
+2 -18
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import 'palette_helper.dart';
@@ -9,13 +10,11 @@ final _themeLogger = DebugLogger();
/// Global function to update adaptive colors from an image URL
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
/// Sets isOnDetailScreen=true so nav bar will also use adaptive colors
/// Uses isolate-based extraction to avoid blocking the main thread
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return;
try {
// Use isolate-based extraction for better performance
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
if (colorSchemes != null && context.mounted) {
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
@@ -33,13 +32,6 @@ void clearAdaptiveColorsOnBack(BuildContext context) {
themeProvider.clearAdaptiveColors();
}
/// Mark that we're entering a detail screen (call this synchronously on init)
/// This ensures the nav bar starts using adaptive colors immediately
void markDetailScreenEntered(BuildContext context) {
final themeProvider = context.read<ThemeProvider>();
themeProvider.setOnDetailScreen(true);
}
class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
bool _useMaterialTheme = false;
@@ -211,12 +203,4 @@ class ThemeProvider extends ChangeNotifier {
_isOnDetailScreen = false;
notifyListeners();
}
/// Set the detail screen flag (used for immediate synchronous updates)
void setOnDetailScreen(bool value) {
if (_isOnDetailScreen != value) {
_isOnDetailScreen = value;
notifyListeners();
}
}
}
+3 -211
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
@@ -11,11 +10,7 @@ import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class AlbumCard extends StatefulWidget {
final Album album;
@@ -37,7 +32,7 @@ class AlbumCard extends StatefulWidget {
State<AlbumCard> createState() => _AlbumCardState();
}
class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
class _AlbumCardState extends State<AlbumCard> {
String? _fallbackImageUrl;
bool _triedFallback = false;
bool _maImageFailed = false;
@@ -49,25 +44,9 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'album',
widget.album.provider,
widget.album.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
_initFallbackImage();
}
@@ -123,176 +102,6 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.album.provider;
String actualItemId = widget.album.itemId;
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
final mapping = widget.album.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.album.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.album.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.album.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.album.provider != 'library') {
actualProvider = widget.album.provider;
actualItemId = widget.album.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.album.provider == 'library') {
libraryItemId = int.tryParse(widget.album.itemId);
} else if (widget.album.providerMappings != null) {
final libraryMapping = widget.album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
@@ -316,13 +125,8 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
if (_isNavigating) return;
_isNavigating = true;
// Start color extraction immediately (fire-and-forget)
// Runs in isolate so won't block Hero animation
// Results are cached, so detail screen gets them instantly
if (imageUrl != null) {
updateAdaptiveColorsFromImage(context, imageUrl);
}
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
@@ -339,18 +143,6 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
});
});
},
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.album,
item: widget.album,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+1 -2
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -106,7 +105,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
if (_albums.isEmpty) {
return Center(
child: Text(
S.of(context)!.noAlbumsFound,
'No albums found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+2 -204
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
@@ -8,13 +7,10 @@ import '../providers/music_assistant_provider.dart';
import '../screens/artist_details_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class ArtistCard extends StatefulWidget {
final Artist artist;
@@ -36,7 +32,7 @@ class ArtistCard extends StatefulWidget {
State<ArtistCard> createState() => _ArtistCardState();
}
class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
class _ArtistCardState extends State<ArtistCard> {
static final _logger = DebugLogger();
String? _fallbackImageUrl;
bool _triedFallback = false;
@@ -49,25 +45,9 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'artist',
widget.artist.provider,
widget.artist.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
// Fetch fallback image once in initState, not during build
_initFallbackImage();
}
@@ -114,176 +94,6 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.artist.provider;
String actualItemId = widget.artist.itemId;
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
final mapping = widget.artist.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.artist.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.artist.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'artist',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.artist.provider == 'library') {
libraryItemId = int.tryParse(widget.artist.itemId);
} else if (widget.artist.providerMappings != null) {
final libraryMapping = widget.artist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.artist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.artist.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.artist.provider != 'library') {
actualProvider = widget.artist.provider;
actualItemId = widget.artist.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.artist.provider == 'library') {
libraryItemId = int.tryParse(widget.artist.itemId);
} else if (widget.artist.providerMappings != null) {
final libraryMapping = widget.artist.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.artist.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
@@ -325,18 +135,6 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
});
});
},
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.artist,
item: widget.artist,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
+1 -2
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -106,7 +105,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
if (_artists.isEmpty) {
return Center(
child: Text(
S.of(context)!.noArtistsFound,
'No artists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+14 -226
View File
@@ -1,19 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../screens/audiobook_detail_screen.dart';
import '../constants/hero_tags.dart';
import '../services/library_status_service.dart';
import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class AudiobookRow extends StatefulWidget {
final String title;
@@ -99,6 +94,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
@@ -115,7 +111,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
if (_audiobooks.isEmpty) {
return Center(
child: Text(
S.of(context)!.noAudiobooks,
'No audiobooks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
@@ -193,7 +189,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
}
}
class _AudiobookCard extends StatefulWidget {
class _AudiobookCard extends StatelessWidget {
final Audiobook audiobook;
final String heroTagSuffix;
final MusicAssistantProvider maProvider;
@@ -204,207 +200,11 @@ class _AudiobookCard extends StatefulWidget {
required this.maProvider,
});
@override
State<_AudiobookCard> createState() => _AudiobookCardState();
}
class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin {
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'audiobook',
widget.audiobook.provider,
widget.audiobook.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.audiobook.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.audiobook.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.audiobook.provider;
String actualItemId = widget.audiobook.itemId;
if (widget.audiobook.providerMappings != null && widget.audiobook.providerMappings!.isNotEmpty) {
final mapping = widget.audiobook.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.audiobook.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.audiobook.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} 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) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
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;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'audiobook',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} 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;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final imageUrl = widget.maProvider.getImageUrl(widget.audiobook, size: 256);
final imageUrl = maProvider.getImageUrl(audiobook, size: 256);
return GestureDetector(
onTap: () {
@@ -412,25 +212,13 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: widget.audiobook,
heroTagSuffix: widget.heroTagSuffix,
audiobook: audiobook,
heroTagSuffix: heroTagSuffix,
initialImageUrl: imageUrl,
),
),
);
},
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: widget.audiobook,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -438,7 +226,7 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
child: Stack(
children: [
Hero(
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + '_${widget.heroTagSuffix}',
tag: HeroTags.audiobookCover + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
@@ -480,7 +268,7 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
),
),
// Progress indicator
if (widget.audiobook.progress > 0)
if (audiobook.progress > 0)
Positioned(
bottom: 0,
left: 0,
@@ -491,7 +279,7 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
bottomRight: Radius.circular(8),
),
child: LinearProgressIndicator(
value: widget.audiobook.progress,
value: audiobook.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 4,
@@ -499,20 +287,20 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
),
),
// Provider icon overlay
if (widget.audiobook.providerMappings?.isNotEmpty == true)
if (audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.audiobook.providerMappings!.first.providerDomain,
domain: audiobook.providerMappings!.first.providerDomain,
),
],
),
),
const SizedBox(height: 8),
Hero(
tag: HeroTags.audiobookTitle + (widget.audiobook.uri ?? widget.audiobook.itemId) + '_${widget.heroTagSuffix}',
tag: HeroTags.audiobookTitle + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix',
child: Material(
color: Colors.transparent,
child: Text(
widget.audiobook.name,
audiobook.name,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
@@ -522,7 +310,7 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
),
),
Text(
widget.audiobook.authorsString,
audiobook.authorsString,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
+2 -5
View File
@@ -3,7 +3,6 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
@@ -612,8 +611,6 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
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) {
@@ -635,8 +632,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Symbols.book_2, fill: 0),
activeIcon: const Icon(Symbols.book_2, fill: 1),
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
-114
View File
@@ -1,114 +0,0 @@
import 'package:flutter/material.dart';
import '../models/media_item.dart';
/// Badge widget that displays a hi-res audio indicator for tracks
/// with high quality audio (lossless codec + sample rate > 48kHz OR bit depth > 16)
class HiResBadge extends StatelessWidget {
final String? tooltip;
final Color? primaryColor;
const HiResBadge({super.key, this.tooltip, this.primaryColor});
/// Lossless audio codecs that qualify for hi-res badge
static const _losslessCodecs = [
'flac',
'wav',
'aiff',
'alac',
'dsf',
'wavpack',
'ape',
'tak',
];
/// Check if track qualifies for hi-res badge, returns tooltip text if so
static String? getTooltip(Track track) {
// Find first available provider mapping with audio format info
final mapping =
track.providerMappings?.cast<ProviderMapping?>().firstWhere(
(m) => m != null && m.available && m.audioFormat != null,
orElse: () => null,
);
final audioFormat = mapping?.audioFormat;
if (audioFormat == null) return null;
final contentType =
(audioFormat['content_type'] as String?)?.toLowerCase();
final sampleRate = audioFormat['sample_rate'] as int?;
final bitDepth = audioFormat['bit_depth'] as int?;
// Must be a lossless codec
if (contentType == null) return null;
final isLossless =
_losslessCodecs.any((codec) => contentType.contains(codec));
if (!isLossless) return null;
// Must be hi-res: sample_rate > 48000 OR bit_depth > 16
final effectiveSampleRate = sampleRate ?? 44100;
final effectiveBitDepth = bitDepth ?? 16;
if (effectiveSampleRate <= 48000 && effectiveBitDepth <= 16) {
return null;
}
return '${effectiveSampleRate ~/ 1000}kHz $effectiveBitDepth-bit';
}
/// Returns HiResBadge if track qualifies for hi-res, null otherwise
static Widget? fromTrack(Track track, {Color? primaryColor}) {
final tooltipText = getTooltip(track);
if (tooltipText == null) return null;
return HiResBadge(tooltip: tooltipText, primaryColor: primaryColor);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Use adaptive primary color muted with white, fallback to theme primary
final baseColor = primaryColor ?? colorScheme.primary;
final color = Color.lerp(baseColor, Colors.white, 0.5)!;
return Tooltip(
message: tooltip ?? 'Hi-Res Audio',
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
border: Border.all(color: color, width: 1.5),
borderRadius: BorderRadius.circular(4),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Hi-Res',
style: TextStyle(
color: color,
fontSize: 9,
fontWeight: FontWeight.w500,
height: 1.1,
),
),
Container(
margin: const EdgeInsets.only(top: 1),
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
child: Text(
'AUDIO',
style: TextStyle(
color: colorScheme.surface,
fontSize: 8,
fontWeight: FontWeight.bold,
height: 1.1,
),
),
),
],
),
),
);
}
}
-166
View File
@@ -1,166 +0,0 @@
import 'package:flutter/material.dart';
import '../services/library_status_service.dart';
/// A widget that rebuilds when the library/favorite status of an item changes.
///
/// This widget listens to the centralized [LibraryStatusService] and only
/// rebuilds when the status of the specified item changes.
///
/// Example:
/// ```dart
/// LibraryStatusBuilder(
/// itemKey: LibraryStatusService.makeKey('album', album.provider, album.itemId),
/// builder: (context, isInLibrary, isFavorite) {
/// return IconButton(
/// icon: Icon(isInLibrary ? Icons.library_add_check : Icons.library_add),
/// onPressed: () => toggleLibrary(),
/// );
/// },
/// )
/// ```
class LibraryStatusBuilder extends StatefulWidget {
final String itemKey;
final Widget Function(BuildContext context, bool isInLibrary, bool isFavorite) builder;
const LibraryStatusBuilder({
super.key,
required this.itemKey,
required this.builder,
});
@override
State<LibraryStatusBuilder> createState() => _LibraryStatusBuilderState();
}
class _LibraryStatusBuilderState extends State<LibraryStatusBuilder> {
late bool _isInLibrary;
late bool _isFavorite;
@override
void initState() {
super.initState();
_updateStatus();
LibraryStatusService.instance.addListener(_onStatusChanged);
}
@override
void didUpdateWidget(LibraryStatusBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemKey != widget.itemKey) {
_updateStatus();
}
}
@override
void dispose() {
LibraryStatusService.instance.removeListener(_onStatusChanged);
super.dispose();
}
void _updateStatus() {
_isInLibrary = LibraryStatusService.instance.isInLibrary(widget.itemKey);
_isFavorite = LibraryStatusService.instance.isFavorite(widget.itemKey);
}
void _onStatusChanged() {
final newInLibrary = LibraryStatusService.instance.isInLibrary(widget.itemKey);
final newFavorite = LibraryStatusService.instance.isFavorite(widget.itemKey);
if (newInLibrary != _isInLibrary || newFavorite != _isFavorite) {
setState(() {
_isInLibrary = newInLibrary;
_isFavorite = newFavorite;
});
}
}
@override
Widget build(BuildContext context) {
return widget.builder(context, _isInLibrary, _isFavorite);
}
}
/// Mixin for StatefulWidgets that need to track library/favorite status.
///
/// This provides a simpler API for widgets that already maintain other state.
///
/// Example:
/// ```dart
/// class _MyWidgetState extends State<MyWidget> with LibraryStatusMixin {
/// @override
/// String get libraryItemKey => LibraryStatusService.makeKey('album', widget.album.provider, widget.album.itemId);
///
/// @override
/// Widget build(BuildContext context) {
/// return IconButton(
/// icon: Icon(isInLibrary ? Icons.check : Icons.add),
/// onPressed: toggleLibrary,
/// );
/// }
/// }
/// ```
mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
/// Override to provide the item key
String get libraryItemKey;
/// Whether the item is currently in the library
bool get isInLibrary => LibraryStatusService.instance.isInLibrary(libraryItemKey);
/// Whether the item is currently a favorite
bool get isFavorite => LibraryStatusService.instance.isFavorite(libraryItemKey);
@override
void initState() {
super.initState();
LibraryStatusService.instance.addListener(_onLibraryStatusChanged);
}
@override
void dispose() {
LibraryStatusService.instance.removeListener(_onLibraryStatusChanged);
super.dispose();
}
void _onLibraryStatusChanged() {
// Trigger rebuild to pick up new status values
if (mounted) {
setState(() {});
}
}
/// Set library status optimistically and return previous value for rollback
bool setLibraryStatus(bool value) {
final previous = isInLibrary;
LibraryStatusService.instance.markLibraryPending(libraryItemKey, previous);
LibraryStatusService.instance.setLibraryStatus(libraryItemKey, value);
return previous;
}
/// Set favorite status optimistically and return previous value for rollback
bool setFavoriteStatus(bool value) {
final previous = isFavorite;
LibraryStatusService.instance.markFavoritePending(libraryItemKey, previous);
LibraryStatusService.instance.setFavoriteStatus(libraryItemKey, value);
return previous;
}
/// Complete a successful library operation
void completeLibraryOperation() {
LibraryStatusService.instance.completeLibraryOperation(libraryItemKey);
}
/// Complete a successful favorite operation
void completeFavoriteOperation() {
LibraryStatusService.instance.completeFavoriteOperation(libraryItemKey);
}
/// Rollback a failed library operation
void rollbackLibraryOperation() {
LibraryStatusService.instance.rollbackLibraryOperation(libraryItemKey);
}
/// Rollback a failed favorite operation
void rollbackFavoriteOperation() {
LibraryStatusService.instance.rollbackFavoriteOperation(libraryItemKey);
}
}
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -14,10 +14,9 @@ class PlayerSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Use select to only rebuild when player data changes (not on every provider update)
final (selectedPlayer, availablePlayers) = context.select<MusicAssistantProvider, (dynamic, List)>(
(p) => (p.selectedPlayer, p.availablePlayers),
);
final maProvider = context.watch<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
final availablePlayers = maProvider.availablePlayers;
final colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing MA content (excluding selected player)
@@ -46,7 +45,7 @@ class PlayerSelector extends StatelessWidget {
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _showPlayerSelector(context),
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
@@ -82,7 +81,11 @@ class PlayerSelector extends StatelessWidget {
);
}
void _showPlayerSelector(BuildContext context) {
void _showPlayerSelector(
BuildContext context,
MusicAssistantProvider provider,
List players,
) {
// Use the new player reveal animation
GlobalPlayerOverlay.showPlayerReveal();
}
+2 -209
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
@@ -8,12 +7,9 @@ import '../providers/music_assistant_provider.dart';
import '../screens/playlist_details_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class PlaylistCard extends StatefulWidget {
final Playlist playlist;
@@ -33,200 +29,9 @@ class PlaylistCard extends StatefulWidget {
State<PlaylistCard> createState() => _PlaylistCardState();
}
class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
class _PlaylistCardState extends State<PlaylistCard> {
bool _isNavigating = false;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'playlist',
widget.playlist.provider,
widget.playlist.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.playlist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.playlist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.playlist.provider;
String actualItemId = widget.playlist.itemId;
if (widget.playlist.providerMappings != null && widget.playlist.providerMappings!.isNotEmpty) {
final mapping = widget.playlist.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.playlist.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.playlist.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'playlist',
itemId: actualItemId,
provider: actualProvider,
);
} 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) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'playlist',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
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;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'playlist',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} 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;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'playlist',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
@@ -262,18 +67,6 @@ class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
});
});
},
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.playlist,
item: widget.playlist,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+1 -2
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -99,7 +98,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
if (_playlists.isEmpty) {
return Center(
child: Text(
S.of(context)!.noPlaylistsFound,
'No playlists found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+2 -209
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
@@ -9,12 +8,9 @@ import '../providers/music_assistant_provider.dart';
import '../screens/podcast_detail_screen.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
import '../services/library_status_service.dart';
import 'provider_icon.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class PodcastCard extends StatefulWidget {
final MediaItem podcast;
@@ -36,200 +32,9 @@ class PodcastCard extends StatefulWidget {
State<PodcastCard> createState() => _PodcastCardState();
}
class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
class _PodcastCardState extends State<PodcastCard> {
bool _isNavigating = false;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'podcast',
widget.podcast.provider,
widget.podcast.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.podcast.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.podcast.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
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) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.podcast.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.podcast.provider != 'library') {
actualProvider = widget.podcast.provider;
actualItemId = widget.podcast.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'podcast',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} 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;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'podcast',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
@@ -268,18 +73,6 @@ class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
});
});
},
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.podcast,
item: widget.podcast,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+1 -2
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -107,7 +106,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
if (_podcasts.isEmpty) {
return Center(
child: Text(
S.of(context)!.noPodcasts,
'No podcasts found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+1 -208
View File
@@ -7,10 +7,6 @@ import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../services/library_status_service.dart';
import '../l10n/app_localizations.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class RadioStationCard extends StatefulWidget {
final MediaItem radioStation;
@@ -30,200 +26,9 @@ class RadioStationCard extends StatefulWidget {
State<RadioStationCard> createState() => _RadioStationCardState();
}
class _RadioStationCardState extends State<RadioStationCard> with LibraryStatusMixin {
class _RadioStationCardState extends State<RadioStationCard> {
bool _isTapping = false;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'radio',
widget.radioStation.provider,
widget.radioStation.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.radioStation.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.radioStation.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.radioStation.provider;
String actualItemId = widget.radioStation.itemId;
if (widget.radioStation.providerMappings != null && widget.radioStation.providerMappings!.isNotEmpty) {
final mapping = widget.radioStation.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.radioStation.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.radioStation.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'radio',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.radioStation.provider == 'library') {
libraryItemId = int.tryParse(widget.radioStation.itemId);
} else if (widget.radioStation.providerMappings != null) {
final libraryMapping = widget.radioStation.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.radioStation.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'radio',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.radioStation.providerMappings != null && widget.radioStation.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.radioStation.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.radioStation.provider != 'library') {
actualProvider = widget.radioStation.provider;
actualItemId = widget.radioStation.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'radio',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.radioStation.provider == 'library') {
libraryItemId = int.tryParse(widget.radioStation.itemId);
} else if (widget.radioStation.providerMappings != null) {
final libraryMapping = widget.radioStation.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.radioStation.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'radio',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
@@ -253,18 +58,6 @@ class _RadioStationCardState extends State<RadioStationCard> with LibraryStatusM
if (mounted) _isTapping = false;
});
},
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.radio,
item: widget.radioStation,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+1 -2
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -99,7 +98,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
if (_radioStations.isEmpty) {
return Center(
child: Text(
S.of(context)!.noRadioStations,
'No radio stations found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+1 -4
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -173,7 +172,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
if (_series.isEmpty) {
return Center(
child: Text(
S.of(context)!.noSeriesFound,
'No series found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
@@ -407,8 +406,6 @@ class _SeriesCard extends StatelessWidget {
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
memCacheWidth: 192,
memCacheHeight: 192,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
+9 -222
View File
@@ -1,14 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import '../services/library_status_service.dart';
import 'media_context_menu.dart';
import 'library_status_builder.dart';
class TrackRow extends StatefulWidget {
final String title;
@@ -101,7 +96,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
if (_tracks.isEmpty) {
return Center(
child: Text(
S.of(context)!.noTracksFound,
'No tracks found',
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
@@ -181,7 +176,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
}
}
class _TrackCard extends StatefulWidget {
class _TrackCard extends StatelessWidget {
final Track track;
final List<Track> tracks;
final int index;
@@ -192,209 +187,13 @@ class _TrackCard extends StatefulWidget {
required this.index,
});
@override
State<_TrackCard> createState() => _TrackCardState();
}
class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'track',
widget.track.provider,
widget.track.itemId,
);
@override
void initState() {
super.initState();
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.track.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.track.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
}
Future<void> _toggleFavorite() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentFavorite = isFavorite;
final newState = !currentFavorite;
// Optimistic update via centralized service
setFavoriteStatus(newState);
try {
bool success;
if (newState) {
String actualProvider = widget.track.provider;
String actualItemId = widget.track.itemId;
if (widget.track.providerMappings != null && widget.track.providerMappings!.isNotEmpty) {
final mapping = widget.track.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => widget.track.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => widget.track.providerMappings!.first,
),
);
actualProvider = mapping.providerDomain;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'track',
itemId: actualItemId,
provider: actualProvider,
);
} else {
int? libraryItemId;
if (widget.track.provider == 'library') {
libraryItemId = int.tryParse(widget.track.itemId);
} else if (widget.track.providerMappings != null) {
final libraryMapping = widget.track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) {
rollbackFavoriteOperation();
return;
}
success = await maProvider.removeFromFavorites(
mediaType: 'track',
libraryItemId: libraryItemId,
);
}
if (success && mounted) {
completeFavoriteOperation();
maProvider.invalidateHomeCache();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
duration: const Duration(seconds: 1),
),
);
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
}
}
Future<void> _toggleLibrary() async {
final maProvider = context.read<MusicAssistantProvider>();
final currentInLibrary = isInLibrary;
final newState = !currentInLibrary;
try {
if (newState) {
String? actualProvider;
String? actualItemId;
if (widget.track.providerMappings != null && widget.track.providerMappings!.isNotEmpty) {
final nonLibraryMapping = widget.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 (widget.track.provider != 'library') {
actualProvider = widget.track.provider;
actualItemId = widget.track.itemId;
} else {
return;
}
}
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.addedToLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.addToLibrary(
mediaType: 'track',
provider: actualProvider,
itemId: actualItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
} else {
int? libraryItemId;
if (widget.track.provider == 'library') {
libraryItemId = int.tryParse(widget.track.itemId);
} else if (widget.track.providerMappings != null) {
final libraryMapping = widget.track.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => widget.track.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId == null) return;
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.removedFromLibrary),
duration: const Duration(seconds: 1),
),
);
}
final success = await maProvider.removeFromLibrary(
mediaType: 'track',
libraryItemId: libraryItemId,
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
}
} catch (e) {
rollbackLibraryOperation();
}
}
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
// Try track image first, then album image
final trackImageUrl = maProvider.getImageUrl(widget.track, size: 256);
final albumImageUrl = widget.track.album != null
? maProvider.getImageUrl(widget.track.album!, size: 256)
final trackImageUrl = maProvider.getImageUrl(track, size: 256);
final albumImageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 256)
: null;
final imageUrl = trackImageUrl ?? albumImageUrl;
final colorScheme = Theme.of(context).colorScheme;
@@ -403,18 +202,6 @@ class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
return RepaintBoundary(
child: GestureDetector(
onTap: () => _playTrack(context, maProvider),
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.track,
item: widget.track,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -454,7 +241,7 @@ class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
const SizedBox(height: 8),
// Track title
Text(
widget.track.name,
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
@@ -464,7 +251,7 @@ class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
),
// Artist name
Text(
widget.track.artistsString,
track.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
@@ -487,8 +274,8 @@ class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
await maProvider.playTracks(
maProvider.selectedPlayer!.playerId,
widget.tracks,
startIndex: widget.index,
tracks,
startIndex: index,
);
}
}
+6 -7
View File
@@ -171,8 +171,8 @@ class _VolumeControlState extends State<VolumeControl> {
@override
Widget build(BuildContext context) {
// Use select to only rebuild when selectedPlayer changes (not on every provider update)
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
final maProvider = context.watch<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
if (player == null) {
return const SizedBox.shrink();
@@ -203,7 +203,6 @@ class _VolumeControlState extends State<VolumeControl> {
onPressed: () async {
// In compact mode, toggle between 0 and 50%
final newVolume = currentVolume < 0.01 ? 50 : 0;
final maProvider = context.read<MusicAssistantProvider>();
await _adjustVolume(maProvider, player.playerId, newVolume / 100.0, 0);
},
);
@@ -217,7 +216,7 @@ class _VolumeControlState extends State<VolumeControl> {
Icons.volume_down,
color: Colors.white70,
),
onPressed: () => _adjustVolume(context.read<MusicAssistantProvider>(), player.playerId, currentVolume, -1),
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, -1),
),
// Slider with floating teardrop indicator
Expanded(
@@ -285,7 +284,7 @@ class _VolumeControlState extends State<VolumeControl> {
setState(() {
_pendingVolume = newVolume;
});
_sendVolumeUpdate(context.read<MusicAssistantProvider>(), player.playerId, newVolume);
_sendVolumeUpdate(maProvider, player.playerId, newVolume);
}
},
onHorizontalDragEnd: (details) {
@@ -299,7 +298,7 @@ class _VolumeControlState extends State<VolumeControl> {
FlutterVolumeController.setVolume(finalVolume);
_systemVolume = finalVolume;
} else {
context.read<MusicAssistantProvider>().setVolume(player.playerId, volumeLevel);
maProvider.setVolume(player.playerId, volumeLevel);
}
_exitPrecisionMode();
@@ -388,7 +387,7 @@ class _VolumeControlState extends State<VolumeControl> {
Icons.volume_up,
color: Colors.white70,
),
onPressed: () => _adjustVolume(context.read<MusicAssistantProvider>(), player.playerId, currentVolume, 1),
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, 1),
),
],
);
+97 -17
View File
@@ -169,14 +169,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.1"
chalkdart:
dependency: transitive
description:
name: chalkdart
sha256: "7ffc6bd39c81453fb9ba8dbce042a9c960219b75ea1c07196a7fa41c2fab9e86"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
characters:
dependency: transitive
description:
@@ -265,6 +257,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.3"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: "4ed7b9fa9924e5a81eb39271e2c2356739dd1039d60a13b86ba6c5f448625086"
url: "https://pub.dev"
source: hosted
version: "1.7.0"
device_info_plus:
dependency: "direct main"
description:
@@ -358,6 +358,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_inappwebview:
dependency: "direct main"
description:
name: flutter_inappwebview
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
name: flutter_inappwebview_internal_annotations
sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://pub.dev"
source: hosted
version: "1.3.0+1"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
@@ -469,6 +533,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: "792aa1e5838a719f29ae52c0773dbb5dd781fc33b1bf87c321b274e55ab51ad1"
url: "https://pub.dev"
source: hosted
version: "0.14.2"
frontend_server_client:
dependency: transitive
description:
@@ -518,7 +590,7 @@ packages:
source: hosted
version: "4.0.2"
image:
dependency: "direct main"
dependency: transitive
description:
name: image
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
@@ -645,14 +717,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.7296"
material_symbols_icons:
dependency: "direct main"
description:
name: material_symbols_icons
sha256: "02555a48e1ec02b16e532dfd4ef13c4f6bf7ec7c20230e58e56641a393433dc3"
url: "https://pub.dev"
source: hosted
version: "4.2892.0"
meta:
dependency: transitive
description:
@@ -669,6 +733,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
url: "https://pub.dev"
source: hosted
version: "6.0.11"
nested:
dependency: transitive
description:
@@ -1210,6 +1282,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: ad0e5786b2acd3be72a3219ef1dde9e1cac071cf4604c685f11b61d63cdd6eb3
url: "https://pub.dev"
source: hosted
version: "1.4.0"
win32:
dependency: transitive
description:
+5 -3
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.9.3-beta+51
version: 2.9.1-beta+49
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -15,7 +15,6 @@ dependencies:
# UI
material_design_icons_flutter: ^7.0.7296
material_symbols_icons: ^4.2815.0
dynamic_color: 1.6.8
palette_generator: ^0.3.3+3
flutter_svg: ^2.0.10+1
@@ -27,6 +26,8 @@ dependencies:
web_socket_channel: ^2.4.0
http: ^1.1.0
cached_network_image: ^3.3.1
flutter_webrtc: ^0.14.1
flutter_inappwebview: ^6.0.0
# Storage
shared_preferences: ^2.2.2
@@ -38,10 +39,10 @@ dependencies:
# Utilities
uuid: ^4.2.1
image: ^4.3.0 # Pure Dart image decoding for isolate-based palette extraction
package_info_plus: ^8.0.0
device_info_plus: ^10.1.0
share_plus: ^10.1.4
mobile_scanner: ^6.0.2
# Audio - Local Playback
just_audio: ^0.9.36
@@ -66,6 +67,7 @@ flutter:
assets:
- assets/images/
- assets/webrtc_engine.js
flutter_launcher_icons:
android: true