Compare commits

...
24 Commits
Author SHA1 Message Date
Chris 00f065fbe9 v3.0.2: Disable dependency metadata for F-Droid 2026-01-28 05:00:57 +00:00
ChrisandClaude Opus 4.5 e440f7b1d7 Bump version to 3.0.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:21:16 +00:00
ChrisandClaude Opus 4.5 5a73d2d65b Add fastlane metadata for F-Droid
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:18:43 +00:00
ChrisandClaude Opus 4.5 3b5a8631a8 Change package ID to com.collotsspot.ensemble
BREAKING CHANGE: Users must uninstall and reinstall the app.
The previous package ID (com.musicassistant.music_assistant) could be
confused with an official Music Assistant app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:55:23 +00:00
ChrisandClaude Opus 4.5 1f3a01c22f Bump version to 2.9.3-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:20:19 +00:00
ChrisandClaude Opus 4.5 27e1b8b746 Fix local player track data disappearing when selected
When selecting the builtin/local player from the player selector overlay,
the track info would disappear because the queue API returns null for
local players. The code was clearing _currentTrack unconditionally.

Now for the builtin player:
- Preserve cached track data instead of clearing it
- Restore from cache if _currentTrack is null
- Update notification with actual track info when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 9be3737a9d Add centralized LibraryStatusService for consistent library/favorite state
- Create LibraryStatusService singleton for single source of truth
- Add LibraryStatusMixin for reactive status in StatefulWidgets
- Convert all card widgets to use centralized service:
  - album_card, artist_card, playlist_card, podcast_card
  - radio_station_card, track_row, audiobook_row
- Convert detail screens: album_details_screen, artist_details_screen
- Fix album stuck in library after removal:
  - Add removeFromCacheByLibraryId() to SyncService
  - Add markCachedItemDeletedByLibraryId() to DatabaseService
  - Update removeFromLibrary() to sync all caches

This ensures library/favorite status changes are immediately reflected
across all instances of the same item throughout the app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 882e8b2ea4 Simplify player selection logic
Remove confusing auto-switch behavior that could change the selected
player unexpectedly right before the user taps play.

Removed settings:
- "Prefer Local Player"
- "Disable Auto-Switch"

New simplified logic:
1. User's manual selection is always respected
2. On app reopen: restore last selected player
3. If selected player goes offline: switch to local player
4. First launch (no history): select local player
5. Local player unavailable: select first available

Kept "Smart Sort" setting for display order only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
ChrisandClaude Opus 4.5 aef9423b42 Improve adaptive color contrast using HCT color space
- Replace HSL-based color adjustments with HCT (Material Design 3 standard)
- Use proper WCAG contrast ratio calculations instead of luminance threshold
- Implement binary search to find minimum tone adjustment for contrast
- Ensure minimum chroma of 36 for primary colors to prevent washed-out highlights
- Add color scheme caching for instant loading on revisits
- Pre-extract colors on album tap for faster initial load

Fixes issue where highlighted library button was darker than unhighlighted
state on albums with dark/muted covers (e.g., Portishead, NIHT).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:14:36 +00:00
Chris c0138828a7 Revise reverse proxy details and remove auth methods
Updated reverse proxy options and removed authentication section.
2026-01-27 08:01:56 +00:00
Chris 53225138a2 chore: Bump version to 2.9.2-beta 2026-01-26 22:29:11 +00:00
ChrisandClaude Opus 4.5 ebc3288bfc Replace library icon with book_2 from Material Symbols
- Add material_symbols_icons package for access to book_2 icon
- Update library icon across all detail screens, context menus, bottom
  nav, and search screen to use Symbols.book_2 with fill parameter
- Add podcast episode detail screen with hero animation and full
  scrollable description
- Add episode context menu support with Play, Play On, Add to Queue,
  and View Details options
- Add sort controls for podcast episodes (newest, oldest, alpha, duration)
- Improve context menu: slightly larger buttons (46px), wider menu (185px),
  neutral text color for labels
- Search library filter now uses filled icon without background highlight

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:13:42 +00:00
ChrisandClaude Opus 4.5 5bd0cef76e Fix context menu width consistency and improve UI polish
- Fix menu width rendering inconsistently on dark album covers by:
  - Adding explicit secondaryContainer/onSecondaryContainer to ColorScheme
  - Wrapping menu content in Theme to ensure FilledButton.tonal uses
    correct colorScheme
  - Constraining text labels to 44px width to prevent row overflow
  - Shorten "Add to library" label to "Add" for better fit

- Standardize button styling across all detail screens:
  - Use FilledButton.tonal for three-dot menu buttons
  - Use filled heart icons with Colors.white70 inactive state
  - Standardize icon size to 25

- Add sort/view controls to artist and audiobook author context menus

- Remove duplicate haptic feedback (keep single haptic in
  MediaContextMenu.show)

- Make context menu dividers more subtle (15% opacity)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:23:54 +00:00
ChrisandClaude Opus 4.5 9f399b438f Add library toggle to all context menus and improve menu contrast
- Add inLibrary getter to base MediaItem class for podcasts/radio
- Add library toggle support to all card widgets (podcast, track, radio, audiobook)
- Add library toggle methods to detail screens (album, playlist, podcast, audiobook)
- Add library toggle for tracks within album and playlist detail screens
- Add library toggle methods to new_library_screen for audiobooks, podcasts, radio
- Update all context menu calls to use proper library toggle callbacks
- Make context menu background 25% lighter when adaptive colors are used
  for better contrast against detail screen backgrounds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 09:40:19 +00:00
ChrisandClaude Opus 4.5 63473226f5 Fix navigation transitions and localize hardcoded strings
Phase 1 audit fixes:
- Use FadeSlidePageRoute for DebugLogScreen navigation (consistent transitions)
- Add volumePrecisionMode and volumePrecisionModeDescription to localizations
- Replace hardcoded empty state strings in all row widgets with localized versions:
  - album_row, artist_row, track_row, playlist_row
  - podcast_row, radio_station_row, audiobook_row, series_row

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:55:20 +00:00
ChrisandClaude Opus 4.5 e3d257a6dc Add hi-res audio badge to track listings
Display a hi-res badge on tracks with lossless codec and either
sample rate > 48kHz or bit depth > 16. Badge uses adaptive primary
color muted with white to match album theme.

- Add HiResBadge widget with vector-drawn badge (no external assets)
- Show badge in album, playlist, and library track listings
- Fixed-width duration column to keep badges aligned
- Tooltip shows exact sample rate and bit depth

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:31:03 +00:00
ChrisandClaude Opus 4.5 671b31de61 Standardize library list item height and add addTracksToQueue API
- Set itemExtent to 67px for consistent 10-item display across all library lists
- Remove vertical padding from ListTile contentPadding for better fit
- Add dedicated addTracksToQueue method to properly add tracks without interrupting playback
- Update album and playlist detail screens to use new queue method
- Add memCacheWidth/Height to series row for consistent image caching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 17:08:20 +00:00
ChrisandClaude Opus 4.5 67a4ebd771 Refactor player selector and optimize library list performance
Use centralized GlobalPlayerOverlay.showPlayerSelectorForAction() for queue
actions, reducing code duplication. Add itemExtent to library lists and reduce
image sizes for smoother scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:25:10 +00:00
ChrisandClaude Opus 4.5 23af5379a6 Fix external source detection for Spotify as music provider (issue #27)
The external source detection was incorrectly flagging Spotify tracks
played through Music Assistant as "external" because it checked for
spotify:// URI patterns without considering whether MA had an active queue.

Changes:
- Add activeQueue field to Player model to track MA queue state
- Fix detection priority: active_queue presence now indicates MA control
- Remove spotify:// from fallback URI checks (needs app_id context)
- Only flag physical inputs (optical, hdmi, etc.) in URI fallback
- Update provider event handling to use same detection logic
- Improve debug logging to include active_queue state

Detection logic priority:
1. app_id == 'music_assistant' → NOT external
2. active_queue has value → NOT external (key fix for Spotify provider)
3. app_id set and != 'music_assistant' → external
4. Fallback: Check physical input URIs only

Fixes #27

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 06:07:15 +00:00
ChrisandClaude Opus 4.5 86bdad5534 Simplify auth to use MA built-in authentication
- Remove auth strategy pattern (NoAuth, BasicAuth, Authelia, MAAuth)
- Simplify AuthManager to use MA's native token-based auth
- Streamline login screen with cleaner UI
- Remove unused auth strategy files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:27:37 +00:00
ChrisandClaude Opus 4.5 7ee27f6e75 Performance optimization: isolate palette extraction and provider selects
- Move palette extraction to isolate using compute() to prevent main thread blocking
- Add image package for pure-Dart isolate-compatible image decoding
- Add extractColorSchemesFromUrl() with LRU cache for downloaded images
- Replace context.watch() with context.select() to reduce unnecessary rebuilds
- Update all detail screens to use isolate-based color extraction
- Optimize library screens and widgets to only rebuild on specific field changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:24:56 +00:00
ChrisandClaude Opus 4.5 633f489bfa Add remote connection documentation
Documents WebRTC remote connection investigation and findings.
Feature on hold due to one-way channel bug. See docs for details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 09:19:20 +00:00
ChrisandClaude Opus 4.5 61503cbe49 Fix Flutter 3.38 slider grey rectangles and library tab scroll
- Replace Material Slider with custom drawn sliders in:
  - expandable_player.dart (progress bar)
  - volume_control.dart (volume slider)
- Add seek debounce to prevent rapid-fire seeks
- Use standard PageScrollPhysics in library to fix multi-tab scroll

Flutter 3.38's Material 3 Slider uses OverlayPortal which causes
grey rectangles in our overlay context. Custom sliders avoid this.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:48:43 +00:00
ChrisandClaude Opus 4.5 0b49c5158c Update to Flutter 3.38 compatibility
- Upgrade Gradle 8.1.0 → 8.7.0
- Upgrade Kotlin 1.9.10 → 2.1.0
- Update intl ^0.19.0 → ^0.20.0
- Fix CardTheme → CardThemeData (Flutter 3.38 API change)
- Update CI workflow to use Flutter 3.38

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:37:23 +00:00
95 changed files with 11830 additions and 5105 deletions
+4 -17
View File
@@ -31,8 +31,8 @@ jobs:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
flutter-version: '3.38.0'
channel: 'master'
cache: true
- name: Cache Gradle dependencies
@@ -63,10 +63,7 @@ jobs:
- name: Setup signing keystore
run: |
# Decode keystore from secret (persistent key for consistent signatures)
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
# Create key.properties file
cat > android/key.properties << EOF
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.KEY_PASSWORD }}
@@ -75,12 +72,7 @@ jobs:
EOF
- name: Build APK
run: |
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
mkdir -p ~/.gradle
cp android/init.gradle ~/.gradle/init.gradle
flutter build apk --release
run: flutter build apk --release
- name: Upload APK artifact
uses: actions/upload-artifact@v4
@@ -91,10 +83,5 @@ jobs:
- name: Upload APK summary
run: |
echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📱 **APK Location:** \`build/app/outputs/flutter-apk/app-release.apk\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📦 **Download:** Check the artifacts section below" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## APK Build Successful!" >> $GITHUB_STEP_SUMMARY
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
+1 -15
View File
@@ -127,25 +127,11 @@ 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., Nginx Proxy Manager, Cloudflare Tunnel).
## Authentication
Ensemble supports multiple authentication methods:
| Method | Status |
|--------|--------|
| Music Assistant native auth | Tested |
| No authentication | Tested |
| Authelia | Implemented, not recently tested |
| HTTP Basic Auth | Implemented, not recently tested |
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
## Requirements
+41
View File
@@ -2,6 +2,33 @@
## 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
@@ -29,9 +56,23 @@
#### 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()
+8 -3
View File
@@ -29,7 +29,7 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace "com.musicassistant.music_assistant"
namespace "com.collotsspot.ensemble"
compileSdk 35
ndkVersion flutter.ndkVersion
@@ -58,8 +58,8 @@ android {
}
defaultConfig {
applicationId "com.musicassistant.music_assistant"
minSdk 21
applicationId "com.collotsspot.ensemble"
minSdkVersion flutter.minSdkVersion
targetSdk 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
@@ -70,6 +70,11 @@ android {
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
}
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
flutter {
@@ -1,4 +1,4 @@
package com.musicassistant.music_assistant
package com.collotsspot.ensemble
import android.util.Log
import android.view.KeyEvent
@@ -10,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
// while also intercepting volume button events
class MainActivity: AudioServiceActivity() {
private val TAG = "EnsembleVolume"
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
private var methodChannel: MethodChannel? = null
private var isListening = false
+2 -2
View File
@@ -1,5 +1,5 @@
buildscript {
ext.kotlin_version = '1.9.10'
ext.kotlin_version = '2.1.0'
repositories {
google()
// Use Maven Central mirrors as fallback for 403 errors
@@ -9,7 +9,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.0'
classpath 'com.android.tools.build:gradle:8.7.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
+2 -2
View File
@@ -22,8 +22,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"
@@ -0,0 +1,328 @@
# Remote Connection - Future Implementation Guide
## Overview
This document provides guidance for implementing remote connection to Music Assistant servers via WebRTC. Remote connection allows the Ensemble app to connect to MA servers outside the local network using Nabu Casa's WebRTC signaling infrastructure.
## Current Status: On Hold
Remote connection was partially implemented but put on hold due to a critical bug where the `ma-api` data channel becomes one-way after 1-3 minutes. See `MA_WEBRTC_BUG_REPORT.md` for details.
---
## Architecture
### How It Works
```
┌─────────────────┐ WebSocket ┌──────────────────────────┐
│ Ensemble App │◄──────────────────►│ MA Server (local WS) │
└─────────────────┘ └──────────────────────────┘
│ │
│ (when local unavailable) │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ SignalingClient│◄──── WebSocket ───►│ signaling.music-assistant│
└─────────────────┘ │ .io/ws │
│ └──────────────────────────┘
│ ICE candidates, SDP │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ WebRTC Peer │◄═══ Data Channel ══►│ MA Server WebRTC │
│ Connection │ (ma-api) │ Endpoint │
└─────────────────┘ └──────────────────────────┘
```
### Components
1. **SignalingClient** - WebSocket connection to `wss://signaling.music-assistant.io/ws`
2. **WebRTCConnection** - Manages RTCPeerConnection and data channels
3. **RemoteBridge** - Bridges local WebSocket API to remote WebRTC connection
### Data Channels
| Channel | Purpose | Traffic Pattern |
|---------|---------|-----------------|
| `ma-api` | JSON API requests/responses | High volume, large payloads |
| `sendspin` | Audio streaming + control | Low JSON, binary audio data |
---
## Key Files to Create/Modify
### New Files
| File | Purpose |
|------|---------|
| `lib/services/remote/signaling_client.dart` | WebSocket client for signaling server |
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection management |
| `lib/services/remote/remote_bridge.dart` | Bridges API calls to WebRTC |
### Files to Modify
| File | Changes |
|------|---------|
| `lib/services/music_assistant_api.dart` | Add remote connection fallback |
| `lib/providers/music_assistant_provider.dart` | Expose remote connection state |
| `pubspec.yaml` | Add `flutter_webrtc` dependency |
---
## Implementation Steps
### Step 1: Add Dependencies
```yaml
# pubspec.yaml
dependencies:
flutter_webrtc: ^0.12.0 # or latest
```
### Step 2: Implement SignalingClient
The signaling client connects to Music Assistant's signaling server and exchanges WebRTC offers/answers.
```dart
class SignalingClient {
WebSocket? _socket;
Future<void> connect(String serverId) async {
_socket = await WebSocket.connect('wss://signaling.music-assistant.io/ws');
// Send connect request
_send({
'type': 'connect-request',
'server_id': serverId,
});
// Listen for messages
_socket!.listen(_handleMessage);
}
void _handleMessage(dynamic data) {
final message = jsonDecode(data);
switch (message['type']) {
case 'answer':
onAnswer?.call(message['sdp']);
break;
case 'ice-candidate':
onIceCandidate?.call(message['candidate']);
break;
}
}
}
```
### Step 3: Implement WebRTCConnection
```dart
class WebRTCConnection {
RTCPeerConnection? _peerConnection;
RTCDataChannel? _apiChannel;
RTCDataChannel? _sendspinChannel;
Future<void> connect(SignalingClient signaling) async {
// Create peer connection with STUN/TURN servers
_peerConnection = await createPeerConnection({
'iceServers': [
{'urls': 'stun:stun.l.google.com:19302'},
// TURN servers provided by signaling
],
});
// Create data channels BEFORE creating offer
_apiChannel = await _peerConnection!.createDataChannel(
'ma-api',
RTCDataChannelInit()..ordered = true,
);
_sendspinChannel = await _peerConnection!.createDataChannel(
'sendspin',
RTCDataChannelInit()..ordered = true,
);
// Create and send offer
final offer = await _peerConnection!.createOffer();
await _peerConnection!.setLocalDescription(offer);
signaling.sendOffer(offer.sdp!);
// Handle answer from server
signaling.onAnswer = (sdp) async {
await _peerConnection!.setRemoteDescription(
RTCSessionDescription(sdp, 'answer'),
);
};
}
}
```
### Step 4: Implement RemoteBridge
The bridge translates between the local WebSocket API format and WebRTC data channel messages.
```dart
class RemoteBridge {
final WebRTCConnection _webrtc;
final StreamController<String> _messageController;
void sendCommand(String json) {
_webrtc.sendToApiChannel(json);
}
Stream<String> get messages => _messageController.stream;
}
```
### Step 5: Integrate with MusicAssistantAPI
```dart
class MusicAssistantAPI {
WebSocket? _localSocket;
RemoteBridge? _remoteBridge;
Future<void> connect(String host, {String? serverId}) async {
try {
// Try local connection first
_localSocket = await WebSocket.connect('ws://$host/ws');
} catch (e) {
// Fall back to remote if serverId provided
if (serverId != null) {
_remoteBridge = RemoteBridge();
await _remoteBridge!.connect(serverId);
}
}
}
}
```
---
## Critical Implementation Details
### 1. Channel Creation Order
The client MUST create data channels BEFORE sending the WebRTC offer. The server expects the client to create:
- `ma-api` - for API traffic
- `sendspin` - for audio streaming (optional)
### 2. Health Monitoring
Implement aggressive health checks because data channels can silently die:
```dart
// Track message counts
int _sentCount = 0;
int _recvCount = 0;
DateTime _lastRecvTime = DateTime.now();
// Health check every 10 seconds
void _healthCheck() {
final timeSinceRecv = DateTime.now().difference(_lastRecvTime);
if (timeSinceRecv.inSeconds > 30) {
// Channel is dead, trigger reconnection
_reconnect();
}
}
```
### 3. Keepalive Messages
Send periodic keepalive on the data channel itself (not just signaling):
```dart
void _sendKeepalive() {
_apiChannel?.send(RTCDataChannelMessage(
jsonEncode({'type': 'ping', 'timestamp': DateTime.now().toIso8601String()}),
));
}
```
### 4. Flow Control
Monitor buffer to prevent overflow:
```dart
void sendMessage(String json) {
if (_apiChannel!.bufferedAmount > 1048576) { // 1MB
// Queue message, don't send
_sendQueue.add(json);
return;
}
_apiChannel!.send(RTCDataChannelMessage(json));
}
```
---
## Known Issues & Pitfalls
### 1. One-Way Channel Death (CRITICAL - UNSOLVED)
The `ma-api` channel stops receiving after 1-3 minutes while `sendspin` continues working. This occurs in both flutter_webrtc and WebView implementations.
**Workaround options:**
- Detect via health check and force reconnection
- Try single-channel multiplexing (see `PLAN_B_SINGLE_CHANNEL.md`)
- Report to MA team and wait for server-side fix
### 2. flutter_webrtc Quirks
- `RTCDataChannel.state` may show "open" when actually dead
- `bufferedAmount` accuracy varies by platform
- `onBufferedAmountLow` callback support inconsistent
### 3. Android Background Throttling
Android aggressively throttles background WebRTC. Consider:
- Foreground service for sustained connections
- Reconnection on app resume
### 4. ICE/TURN Issues
- aiortc (server-side) has known TURN + DataChannel bugs
- May need ICE restart on network changes
---
## Testing Checklist
- [ ] Connect via remote when local unavailable
- [ ] Verify API commands work (players/all, library queries)
- [ ] Test sustained connection (30+ minutes)
- [ ] Test app backgrounding and resume
- [ ] Test network transitions (WiFi ↔ cellular)
- [ ] Verify reconnection after channel death
- [ ] Test sendspin audio streaming (if implemented)
---
## Useful Resources
- [Music Assistant Remote Access Docs](https://music-assistant.io/docs/remote-access/)
- [flutter_webrtc Package](https://pub.dev/packages/flutter_webrtc)
- [WebRTC Data Channel MDN](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel)
- Signaling server: `wss://signaling.music-assistant.io/ws`
---
## Files in This Directory
| File | Description |
|------|-------------|
| `FUTURE_IMPLEMENTATION_GUIDE.md` | This file - implementation guide |
| `MA_WEBRTC_BUG_REPORT.md` | Bug report for MA team |
| `WEBRTC_DISCONNECT_FIXES.md` | All fixes attempted |
| `WEBRTC_PROGRESS.md` | Progress log |
| `PLAN_B_SINGLE_CHANNEL.md` | Alternative single-channel approach |
| `webrtc-failure-logs.txt` | Raw failure logs |
| `console-export-*.log` | Browser console exports |
---
## Quick Start for Future Implementation
1. Read this guide fully
2. Review `WEBRTC_DISCONNECT_FIXES.md` for all attempted fixes
3. Check if MA team has fixed server-side issues
4. Start with basic implementation, add health monitoring early
5. Test extensively before shipping - the failure mode is subtle
@@ -0,0 +1,127 @@
# WebRTC Data Channel Failure on Android - Bug Report
## Summary
When connecting to Music Assistant via WebRTC remote access (Nabu Casa), the `ma-api` data channel becomes **one-way** (can send, cannot receive) after 1-3 minutes of use, while the `sendspin` channel continues working normally.
## Environment
- **MA Server**: 2.8.0b7 (Home Assistant add-on)
- **Client**: Android app (Flutter) connecting via Nabu Casa remote access
- **Remote Access**: WebRTC via `wss://signaling.music-assistant.io/ws`
## Reproduction Steps
1. Connect to MA server via WebRTC remote access (not local network)
2. Use the app normally - browse library, view players
3. After 1-3 minutes, the connection degrades
4. Commands time out, but signaling ping/pong continues working
## Observed Behavior
### Timeline from logs:
```
07:14:20 - Working normally
Health: API sent:51 recv:54 (balanced)
07:15:57 - Receive stops
Health: API sent:97 recv:97, lastRecv:12s ago
07:16:02 - Timeouts begin
"TimeoutException: Command timeout: players/all"
07:16:17 - Sendspin still works!
"Sendspin: Received server/time response"
Health: API sent:114 recv:97, lastRecv:27s ago
07:17:30 - Forced reconnect
Health: API sent:137 recv:97, lastRecv:42s ago
"Health check detected stale connection"
```
### Key Observation
| Channel | Traffic Pattern | Status |
|---------|-----------------|--------|
| `ma-api` | High (10+ msg/s, large payloads) | **FAILS** - stops receiving |
| `sendspin` | Low (~1 msg/5s, small payloads) | **SURVIVES** - continues working |
| Signaling | Ping/pong every 15s | **SURVIVES** - continues working |
The WebRTC peer connection remains in "connected" state. ICE connection shows "completed". Only the ma-api data channel stops receiving.
## Testing Performed
### Test 1: flutter_webrtc (Native WebRTC)
- **Result**: ma-api channel dies after 1-3 minutes
- **Sendspin**: Continues working
### Test 2: WebView-based WebRTC (Browser engine)
- **Hypothesis**: Use Android WebView's browser-native WebRTC, since MA Web UI works
- **Implementation**: Hidden InAppWebView running JavaScript WebRTC engine
- **Result**: **Same failure mode** - ma-api dies, sendspin survives
- **Conclusion**: Not a flutter_webrtc bug
### Test 3: Various Data Channel Settings
- Tried `ordered: false` with `maxRetransmits: 3`
- Tried letting server create channels (failed - server expects client to create)
- No improvement
## Analysis
The identical failure in both native and WebView WebRTC implementations suggests the issue is:
1. **Server-side** - Different handling of ma-api vs sendspin channels
2. **TURN relay** - High-traffic channel may hit limits
3. **Not client-specific** - Both implementations fail identically
### Questions for MA Team
1. Are the two data channels (`ma-api`, `sendspin`) created with different settings?
2. Is there any server-side buffering or rate limiting that could affect high-traffic channels?
3. Does the MA Web UI use different data channel configurations?
4. Are there known issues with the TURN relay for sustained high-traffic channels?
## Comparison: MA Web UI vs Android App
| Aspect | MA Web UI | Android App |
|--------|-----------|-------------|
| Platform | Browser tab (visible, active) | Background-capable app |
| WebRTC | Browser native | flutter_webrtc / WebView |
| Result | Works | Fails after 1-3 min |
The MA Web UI runs in a **visible, active browser tab**. Android may throttle background apps differently, but this doesn't explain why sendspin survives while ma-api dies on the same connection.
## Logs
### Health Check Pattern (shows channel death)
```
07:14:25 API sent:51 recv:54, lastRecv:2s ago <- Working
07:15:35 API sent:87 recv:91, lastRecv:2s ago <- Working
07:16:05 API sent:108 recv:97, lastRecv:17s ago <- DYING (gap growing)
07:16:15 API sent:114 recv:97, lastRecv:27s ago <- DEAD
07:16:45 API sent:130 recv:97, lastRecv:27s ago <- Still sending, no receive
07:17:30 API sent:137 recv:97, lastRecv:42s ago <- Forced reconnect
```
### Sendspin Still Working During Failure
```
07:16:17 Sendspin: Received server/time response <- Still alive!
07:16:47 Sendspin: Received server/time response <- Still alive!
```
## Requested Information
1. Data channel creation code/settings for both channels
2. Any server-side differences in channel handling
3. TURN server configuration details
4. Whether this has been reported by other Android/mobile clients
## Workaround
Currently using local WebSocket connection when on same network. Remote access via WebRTC is unusable for sustained use.
---
**Reporter**: Ensemble Android App Developer
**Date**: 2026-01-24
@@ -0,0 +1,149 @@
# Plan B: Single Data Channel Multiplexing
## Why This Might Be Needed
If upgrading flutter_webrtc doesn't fix the one-way channel failure, the root cause is likely a bug in how flutter_webrtc (or underlying libwebrtc) handles **multiple data channels**.
Evidence:
- MA Web UI works fine with local playback (same server, browser WebRTC)
- Our app uses flutter_webrtc which has known issues:
- [Issue #1428](https://github.com/flutter-webrtc/flutter-webrtc/issues/1428): Threading issues causing data loss
- [Issue #974](https://github.com/flutter-webrtc/flutter-webrtc/issues/974): EventChannel intermittently fails
- Pattern matches: second channel survives while first dies
## Current Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├────────────────────┬────────────────────────────┤
│ ma-api channel │ sendspin channel │
│ (JSON API) │ (JSON + binary audio) │
│ SCTP stream 0 │ SCTP stream 1 │
└────────────────────┴────────────────────────────┘
```
## Proposed Architecture
```
┌─────────────────────────────────────────────────┐
│ WebRTC Peer Connection │
├─────────────────────────────────────────────────┤
│ unified channel │
│ (multiplexed: API + Sendspin text + binary) │
│ SCTP stream 0 │
└─────────────────────────────────────────────────┘
```
## Multiplexing Protocol
### Message Framing
Each message has a 1-byte type prefix:
| Prefix | Type | Content |
|--------|------|---------|
| 0x00 | API | JSON text (MA API request/response) |
| 0x01 | Sendspin Text | JSON text (Sendspin control messages) |
| 0x02 | Sendspin Binary | Raw bytes (PCM audio data) |
### Implementation
#### Sending (Client → Server)
```dart
void sendApiMessage(String json) {
final prefix = Uint8List.fromList([0x00]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x00;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinText(String json) {
final prefix = Uint8List.fromList([0x01]);
final payload = utf8.encode(json);
final message = Uint8List(1 + payload.length);
message[0] = 0x01;
message.setRange(1, message.length, payload);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
void sendSendspinBinary(Uint8List audio) {
final message = Uint8List(1 + audio.length);
message[0] = 0x02;
message.setRange(1, message.length, audio);
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
}
```
#### Receiving (Server → Client)
```dart
void _onMessage(RTCDataChannelMessage message) {
final data = message.binary;
if (data == null || data.isEmpty) return;
final type = data[0];
final payload = data.sublist(1);
switch (type) {
case 0x00: // API
final json = utf8.decode(payload);
_handleApiMessage(json);
break;
case 0x01: // Sendspin text
final json = utf8.decode(payload);
_handleSendspinText(json);
break;
case 0x02: // Sendspin binary
_handleSendspinBinary(payload);
break;
}
}
```
## Server-Side Changes Required
The Music Assistant server would need to:
1. Accept a single "unified" data channel instead of separate ma-api and sendspin channels
2. Multiplex responses using the same framing protocol
3. This requires changes to the MA remote connection code
**Alternative**: Keep server unchanged, do multiplexing in a proxy layer on the client.
## Client-Only Approach (No Server Changes)
If we can't change the server, we could:
1. Still create two channels to the server (ma-api + sendspin)
2. But internally route them through a single "virtual" channel for flutter_webrtc
3. This is more complex and may not avoid the underlying bug
## Files to Modify
| File | Changes |
|------|---------|
| `webrtc_connection.dart` | Single channel creation, multiplexing logic |
| `remote_bridge.dart` | Route messages based on type prefix |
## Risks
1. **Server compatibility**: Requires MA server changes or complex client proxy
2. **Performance**: Extra byte per message, decode overhead
3. **Binary handling**: Must handle all messages as binary (not text)
## Testing Plan
1. Implement client-side changes
2. Test with modified server (if available) or mock
3. Verify both API and Sendspin traffic flows correctly
4. Long-duration stability test
## Decision Point
Try this approach if:
- flutter_webrtc 0.14.x doesn't fix the issue
- The one-way failure pattern continues
- We've confirmed the issue is multi-channel specific
@@ -0,0 +1,406 @@
# WebRTC Data Channel Disconnect Fixes
## Document Purpose
Track all fixes attempted for the WebRTC data channel disconnection issues in Ensemble's remote connection feature. This document should be referenced when debugging future disconnection problems.
---
## Problem Summary
The WebRTC data channel between the Ensemble mobile app and Music Assistant server can fail in several ways:
1. **One-way failure**: Data channel appears "open" but only sends work, receives stop
2. **Silent disconnect**: No error events fire, channel just stops working
3. **ICE failure**: ICE connection fails, properly detected but causes disconnect
4. **Signaling disconnect**: Signaling WebSocket closes, causing ICE/WebRTC to fail
---
## Key Files
| File | Purpose |
|------|---------|
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection, data channel management |
| `lib/services/remote/remote_bridge.dart` | WebSocket bridge, health monitoring, reconnection logic |
| `lib/services/remote/signaling_client.dart` | Signaling server WebSocket connection |
---
## Implemented Fixes (Chronological)
### Fix 1: Data Channel Keepalive (Original)
**Problem**: Data channel could silently die without any error events firing.
**Solution**: Added periodic keepalive ping/pong on the data channel itself.
**Code** (`webrtc_connection.dart`):
- `_keepaliveTimer`: Runs every 10 seconds
- `_checkKeepalive()`: Checks time since last data received
- `_sendKeepalivePing()`: Sends JSON ping message
- `_recordDataReceived()`: Called on every message receipt
**Constants**:
```dart
static const _keepaliveInterval = Duration(seconds: 10);
static const _keepaliveTimeout = Duration(seconds: 20); // Reduced from 30
static const _maxKeepaliveMisses = 2; // Reduced from 3
```
### Fix 2: ICE Recovery
**Problem**: ICE connection could temporarily disconnect and might auto-recover.
**Solution**: Added ICE recovery timer before declaring failure.
**Code** (`webrtc_connection.dart`):
- `_startIceRecovery()`: Starts 5-second timer when ICE disconnects
- `_cancelIceRecovery()`: Cancels if ICE recovers on its own
- `_attemptIceRestart()`: Calls `restartIce()` if recovery timer expires
### Fix 3: Flow Control (Buffer Overflow Prevention)
**Problem**: Fire-and-forget sends could overflow the 16MB SCTP buffer, causing silent channel death.
**Solution**: Added send-side flow control with buffering.
**Code** (`webrtc_connection.dart`):
```dart
static const _maxBufferedAmount = 1048576; // 1MB threshold
static const _bufferLowThreshold = 65536; // 64KB resume threshold
bool _sendPaused = false;
final _sendQueue = <String>[];
```
**Modified `sendRaw()`**:
- Checks `bufferedAmount` before sending
- Queues messages if buffer > 1MB
- `onBufferedAmountLow` callback drains queue
### Fix 4: Health Check Stale Detection
**Problem**: Health check could see "30s since last data" but not trigger reconnection.
**Solution**: Reduced stale detection threshold from 45s to 30s.
**Code** (`remote_bridge.dart`):
```dart
if (_state == RemoteBridgeState.connected && timeSinceLastData > 30 && !_isReconnecting) {
// Force reconnection
}
```
### Fix 5: Reduced Keepalive Timeouts
**Problem**: 30s timeout × 3 misses = 90s minimum before detection.
**Solution**: Reduced to 20s timeout × 2 misses = ~40s detection.
### Fix 6: Recv Stagnation Detection
**Problem**: One-way failure where recv stops but sent continues not detected quickly enough.
**Solution**: Track recv count across health checks. If recv stuck while sent increases for 2 consecutive checks (20s), force reconnection.
**Code** (`remote_bridge.dart`):
```dart
// Track across health checks
int _lastHealthCheckRecvCount = 0;
int _lastHealthCheckSentCount = 0;
int _recvStagnationCount = 0;
// In health check:
if (recvStuck && sentIncreased) {
_recvStagnationCount++;
if (_recvStagnationCount >= 2) {
// Force reconnection - channel is one-way
}
}
```
**Detection time**: ~20 seconds (2 × 10s health check interval)
---
## Log Patterns to Watch For
### Healthy Connection
```
Health check - API sent:93 recv:88 ... lastRecv:0s ago
```
- `sent` and `recv` both increasing
- `lastRecv` low (< 5s typically)
### One-Way Failure (CRITICAL)
```
Health check - API sent:206 recv:163 ... lastRecv:30s ago
Health check - API sent:218 recv:163 ... lastRecv:40s ago
```
- `sent` increases, `recv` STUCK
- `lastRecv` keeps increasing
- Data channel still shows "open"
### Signaling Disconnect
```
WebRTC: Signaling disconnected
... (6-16 seconds later) ...
WebRTC: ICE connection state: RTCIceConnectionStateDisconnected
WebRTC: ICE connection state: RTCIceConnectionStateFailed
```
### Network Failure (DNS)
```
Signaling: WebSocket closed
... (reconnection attempt) ...
Signaling: Failed to connect: SocketException: Failed host lookup: 'signaling.music-assistant.io'
```
This indicates the phone lost network connectivity entirely.
### Reconnection Storm (Signaling Instability)
```
Signaling: Connecting to wss://signaling.music-assistant.io/ws
Signaling: Sending connect-request
Signaling: WebSocket closed ← Closes within 100-500ms
WebRTC: Signaling disconnected
RemoteBridge: Scheduling reconnect attempt X/10 in Ys
```
Rapid reconnection loop - signaling never stays connected long enough.
### Flow Control Engaged
```
WebRTC: ⚠️ Buffer full (1234567 bytes) or paused - queueing message
WebRTC: Buffer drained to 12345 bytes - draining 5 queued messages
WebRTC: ✅ Send queue drained (5 messages) - resuming normal sends
```
### Keepalive Detection
```
WebRTC: ⚠️ Keepalive timeout! No data for 22s (missed: 1/2)
WebRTC: ❌ Data channel appears dead - triggering reconnection
```
---
## Known Issues / Limitations
### 1. flutter_webrtc Limitations
- `RTCDataChannel.state` may show "open" when actually dead (Issue #843)
- `bufferedAmount` may not be accurate on all platforms
- `onBufferedAmountLow` callback support varies
### 2. SCTP Receive Window
- If server sends faster than client processes, receive window fills
- This causes server-side to stop sending, client sees no data
- No way to monitor receive-side buffer from client
### 3. TURN Server Issues
- aiortc (server-side) has known issues with TURN + DataChannel (#1156)
- Data channel may fail silently when using TURN relay
### 4. Network Transitions
- WiFi → Cellular or vice versa can cause ICE to fail
- App backgrounding on mobile can close WebSocket/WebRTC
---
## Root Cause Categories
### Category A: Network Issues (Not Code Bugs)
These are environmental issues, not bugs in our code:
- **DNS failure**: `Failed host lookup` - Phone lost internet
- **WiFi/Cellular switch**: Network changed, connections dropped
- **Server unreachable**: Signaling server down or blocked
- **Firewall/NAT issues**: TURN relay needed but unavailable
**Evidence**: Signaling WebSocket closes, DNS lookups fail, reconnection attempts fail repeatedly.
### Category B: WebRTC/SCTP Issues (Partial Code Control)
These can be mitigated but not fully prevented:
- **One-way channel death**: Channel appears open but data stops flowing
- **Buffer overflow**: Too much data sent, SCTP buffer exceeded
- **ICE failure**: Network path lost, no recovery possible
**Evidence**: Data channel shows "open" but `recv` stuck, `lastRecv` increasing.
### Category C: Code Bugs (Fixable)
Issues in our implementation:
- **Detection too slow**: Timeouts too long, misses too many
- **Recovery not triggered**: State checks wrong, reconnection not started
- **Resource leaks**: Timers not cancelled, connections not cleaned up
**Evidence**: Logs show problem but no action taken, or wrong action taken.
---
## Debugging Checklist
When investigating a disconnect:
1. **Find the last successful response**:
```
grep "WebRTC API → WS" logs.txt | head -5
```
2. **Check health check progression**:
```
grep "Health check" logs.txt
```
Look for `recv` getting stuck while `sent` increases.
3. **Check for signaling issues**:
```
grep -i "signaling" logs.txt
```
4. **Check ICE state changes**:
```
grep "ICE connection state" logs.txt
```
5. **Check for keepalive detection**:
```
grep -i "keepalive" logs.txt
```
6. **Check buffer status**:
```
grep -E "(buffer|queue|paused)" logs.txt
```
---
## Potential Future Improvements
### 1. Server-Side Backpressure
MA protocol doesn't have explicit backpressure. Could add:
- Client-side request throttling
- Application-level ACKs for critical messages
### 2. More Aggressive Detection
- Track recv count across health checks, detect stagnation
- Shorter keepalive interval (5s instead of 10s)
### 3. Proactive Health Signals
- Monitor `bufferedAmount` trend (increasing = problem)
- Track round-trip latency of keepalive pings
### 4. Connection Quality Metrics
- Expose packet loss, jitter from WebRTC stats
- Use to predict imminent failure
---
## Test Scenarios
| Scenario | How to Test | Expected Behavior |
|----------|-------------|-------------------|
| Normal usage | Browse library, play music | No warnings |
| Heavy sync | Pull-to-refresh library | Buffer may queue, should drain |
| Long session | Leave connected 2+ hours | Should stay connected |
| Rapid navigation | Quick tab switching | Brief queue usage, recovers |
| Network change | WiFi → Cellular | Should detect and reconnect |
| App background | Background for 5+ min | May disconnect, should reconnect |
---
## Observed Failure Patterns
### Pattern 1: One-Way Death After Library Sync
**Observed**: 2026-01-23 (multiple times)
```
17:13:06.175 - Last response (playlists sync)
17:13:06.264 - Local batch save
-- 5 seconds silence --
17:13:11.005 - Send players/all → NO RESPONSE
17:13:13.914 - ICE state: completed (looks healthy!)
17:13:28 - Keepalive detects timeout
17:13:38 - Reconnection triggered
```
**Characteristics**:
- Often happens after library sync (large batch responses)
- ICE shows "completed" even though data channel is dead
- Signaling ping/pong continues working
- Only data channel receive is affected
**Hypothesis**:
- Server-side SCTP buffer exhausted from sending large responses?
- Client receive window full, server blocks?
- flutter_webrtc onMessage callback stops firing?
### Pattern 2: Reconnection Storm / Signaling Instability
**Observed**: 2026-01-23 17:30-17:31
```
17:31:05.093 - Signaling: Connecting
17:31:05.216 - Signaling: Sending connect-request
17:31:05.218 - Signaling: WebSocket closed (123ms later!)
17:31:05.218 - WebRTC: Signaling disconnected
```
**Characteristics**:
- Signaling WebSocket closes almost immediately after connect
- Multiple rapid reconnection attempts (storm)
- Connection never stable long enough for data flow
- Often happens after network instability
- Artist detail, albums etc don't load because connection keeps failing
**Possible Causes**:
- Network instability (WiFi/cellular issues)
- Signaling server rate limiting
- DNS resolution failures
- Server rejecting rapid reconnection attempts
**Side Effects**:
- UI shows errors like "Not connected to Music Assistant server"
- Artist/album detail screens fail to load
- Players list empty or stale
### Pattern 3: API Channel Dies While Sendspin Lives
**Observed**: 2026-01-23 17:53
```
17:53:35.755 - Send album_tracks request → NO RESPONSE
17:53:40.596 - Health check: recv stuck at 197, sent increased
17:53:55.204 - Sendspin channel RECEIVES server/time response!
17:54:02.790 - Keepalive detects dead API channel
```
**Characteristics**:
- API data channel (ma-api) stops receiving
- Sendspin data channel continues working normally
- Same WebRTC peer connection, different data channels
- This proves it's NOT a network issue - Sendspin traffic flows fine
**Hypothesis**:
- flutter_webrtc bug where `onMessage` callback stops firing for one channel
- Possible memory/GC issue with the callback
- Server-side might be sending to wrong channel?
**Key Evidence**:
- Recv count stays at 197 for API
- Sendspin recv count increases (7→8)
- Both channels use same peer connection
---
## Version History
| Date | Build | Changes |
|------|-------|---------|
| 2026-01-23 | ~v49 | Initial flow control implementation |
| 2026-01-23 | ~v49 | Reduced keepalive timeout 30→20s, misses 3→2 |
| 2026-01-23 | ~v49 | Reduced health check stale threshold 45→30s |
| 2026-01-23 | ~v49 | Fixed excessive "queue drained" logging |
| 2026-01-23 | ~v49 | Created WEBRTC_DISCONNECT_FIXES.md documentation |
| 2026-01-23 | ~v49 | Added recv stagnation detection (20s detection) |
| 2026-01-23 | ~v49 | Documented Pattern 2: Reconnection storm / signaling instability |
| 2026-01-23 | ~v49 | Fixed race condition in _scheduleReconnect (multiple simultaneous calls) |
| 2026-01-23 | ~v49 | Added WebSocket close code/reason logging to signaling |
| 2026-01-23 | ~v49 | **CRITICAL FIX**: _reconnectWebRTC() was returning immediately due to _isReconnecting already being true |
| 2026-01-23 | ~v49 | Fixed old WebRTC callbacks firing during reconnection cleanup (clear callbacks before disconnect) |
| 2026-01-23 | ~v49 | Fixed onStateChanged handler calling _handleWebRTCDisconnection during active reconnection |
---
## References
- flutter_webrtc Issue #843: Data channel state unreliable
- aiortc Issue #1156: TURN + DataChannel bugs
- WebRTC SCTP: Max buffer is 16MB, recommended threshold 1MB
- Music Assistant remote protocol: No explicit flow control
+118
View File
@@ -0,0 +1,118 @@
# WebRTC Remote Connection Progress
## Problem Statement
The flutter_webrtc library has reliability issues causing data channel failures during remote connections via Nabu Casa. The ma-api data channel becomes one-way (can send, but receives stop) after some time, while the sendspin channel continues working.
## Approaches Tried
### 1. Original Implementation (flutter_webrtc)
- **Status**: Fails
- **Issue**: ma-api data channel goes one-way after some time
- **Files**: `lib/services/remote/webrtc_connection.dart`
### 2. WebView-based WebRTC Implementation
- **Status**: Same failure mode
- **Hypothesis**: Use browser-native WebRTC via InAppWebView, since MA Web UI works
- **Result**: Same one-way channel death occurs
- **Key Finding**: This proves the issue is NOT specific to flutter_webrtc
## Key Observations
From the logs:
```
lastRecv:26s ago <- No messages received in 26 seconds
API sent:674 recv:618 <- Gap growing, responses stopped
```
- Signaling ping/pong continues working
- Sendspin channel continues receiving (`server/time` responses)
- Only ma-api channel becomes one-way
- WebRTC peer connection itself remains "connected"
## Differences from Working MA Web UI
The MA Web UI runs in a **visible, active browser tab**. Our implementations:
1. flutter_webrtc: Native WebRTC in background app
2. WebView: Browser WebRTC in **hidden 1x1 pixel WebView**
Android aggressively throttles hidden WebViews to save battery.
## Current Implementation (WebView)
### Files Created
| File | Purpose |
|------|---------|
| `lib/services/remote/webview_webrtc_connection.dart` | Dart WebView wrapper |
| `assets/webrtc_engine.js` | JavaScript WebRTC engine |
### Files Modified
| File | Changes |
|------|---------|
| `pubspec.yaml` | Added `flutter_inappwebview: ^6.0.0` |
| `lib/services/remote/remote_bridge.dart` | Uses `WebViewWebRTCConnection` |
| `lib/providers/music_assistant_provider.dart` | Exposes WebView widget |
| `lib/main.dart` | Adds WebView to widget tree |
### Architecture
```
MusicAssistantAPI -> localhost WebSocket -> RemoteBridge
|
WebViewWebRTCConnection
|
Hidden InAppWebView
|
JavaScript WebRTC Engine
|
MA Server via TURN
```
## Current Fixes Being Tested
### 1. Prevent WebView Throttling
- Changed WebView position from off-screen (-10,-10) to on-screen (0,0) with Opacity(0)
- Added various InAppWebViewSettings to prevent background throttling
### 2. Data Channel Settings
- Changed from `ordered: true` to `ordered: false` with `maxRetransmits: 3`
- Hypothesis: Ordered delivery may cause buffer bloat issues
### 3. Buffer Monitoring
- Added `bufferedAmount` checking in JavaScript
- Logs warning when buffer exceeds 1MB
- Added `getChannelHealth()` function for diagnostics
### 4. Reduced Logging
- Removed per-message logging (was filling logs too fast)
- Keep only state changes, errors, and warnings
## Experiments Completed
### 5. Server-Created Channels (Failed)
- **Hypothesis**: Let server create data channels to avoid ID collisions
- **Result**: Connection timeout - server expects CLIENT to create channels
- **Learning**: Client must create channels before sending offer
## Next Steps to Try
1. **Make WebView actually visible** - Android may still throttle Opacity(0) WebView
2. **Single data channel** - Use only ma-api, disable sendspin
3. **Periodic channel recreation** - Detect stale channel and recreate
4. **Report to MA team** - Browser WebRTC also fails, suggesting server-side issue
## Test APK Location
`/home/chris/Ensemble/ensemble-webview-webrtc.apk`
## Useful Debug Commands
Check channel health (in JS console or via Dart):
```javascript
getChannelHealth()
// Returns: { peerConnection, iceConnection, apiChannel, apiBuffered, sendspinChannel, sendspinBuffered, signalingConnected }
```
## Open Questions
1. Why does sendspin channel survive when ma-api dies?
2. Is there a difference in how MA server handles the two channels?
3. Does MA Web UI use different data channel settings?
4. Is there a TURN relay issue specific to high-traffic channels?
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
New package ID (com.collotsspot.ensemble) - reinstall required.
Prepares for F-Droid and Play Store submission.
@@ -0,0 +1 @@
Add fastlane metadata for F-Droid.
@@ -0,0 +1 @@
Disable dependency metadata in APK for F-Droid compliance.
@@ -0,0 +1,13 @@
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
Features:
* Stream music directly to your phone with background playback
* Control any speaker or device connected to Music Assistant
* Multi-room grouping capabilities
* Queue management with drag-to-reorder
* Background playback with media notifications
* Fast ~300ms response for play/pause controls
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

@@ -0,0 +1 @@
Unofficial mobile client for Music Assistant
@@ -0,0 +1 @@
Ensemble
+6
View File
@@ -32,6 +32,12 @@ class HeroTags {
/// Podcast title hero tag prefix
static const String podcastTitle = 'podcast_title_';
/// Podcast episode cover hero tag prefix
static const String podcastEpisodeCover = 'podcast_episode_cover_';
/// Podcast episode title hero tag prefix
static const String podcastEpisodeTitle = 'podcast_episode_title_';
/// Playlist cover hero tag prefix
static const String playlistCover = 'playlist_cover_';
+32 -2
View File
@@ -51,7 +51,7 @@
"description": "Hint for port field"
},
"portDescription": "Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.",
"portDescription": "Leave blank unless using a custom port",
"@portDescription": {
"description": "Description for port field"
},
@@ -604,6 +604,11 @@
"tracksAddedToQueue": "Tracks added to queue",
"@tracksAddedToQueue": {
"description": "Snackbar message for tracks added to queue"
},
"trackAddedToQueue": "Track added to queue",
"@trackAddedToQueue": {
"description": "Confirmation when tracks added"
},
@@ -1069,6 +1074,10 @@
"sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year",
"sortBySeriesOrder": "Sort by series order",
"sortByDuration": "Sort by duration",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"viewDetails": "View details",
"listView": "List view",
"gridView": "Grid view",
"noBooksInSeries": "No books found in this series",
@@ -1087,11 +1096,12 @@
"showFavoritePodcasts": "Show a row of your favorite podcasts",
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
"favorite": "Favorite",
"addedToFavorites": "Added to favorites",
"removedFromFavorites": "Removed from favorites",
"addedToLibrary": "Added to library",
"removedFromLibrary": "Removed from library",
"addToLibrary": "Add to library",
"addToLibrary": "Add",
"unknown": "Unknown",
"noUpcomingTracks": "No upcoming tracks",
"showAll": "Show all",
@@ -1207,11 +1217,21 @@
"description": "Bottom sheet title for adding album to queue"
},
"addToQueue": "Add to queue",
"@addToQueue": {
"description": "Add item to queue on selected player"
},
"addToQueueOn": "Add to queue on...",
"@addToQueueOn": {
"description": "Bottom sheet title for adding to queue"
},
"startRadio": "Start radio",
"@startRadio": {
"description": "Start radio on selected player"
},
"startRadioOn": "Start {name} radio on...",
"@startRadioOn": {
"description": "Bottom sheet title for starting radio",
@@ -1357,6 +1377,16 @@
"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"
+78 -14
View File
@@ -64,7 +64,8 @@ import 'app_localizations_fr.dart';
/// be consistent with the languages listed in the S.supportedLocales
/// property.
abstract class S {
S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
S(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
@@ -84,7 +85,8 @@ abstract class S {
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
@@ -162,7 +164,7 @@ abstract class S {
/// Description for port field
///
/// In en, this message translates to:
/// **'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.'**
/// **'Leave blank unless using a custom port'**
String get portDescription;
/// Username field label
@@ -759,12 +761,18 @@ abstract class S {
/// **'Album added to queue'**
String get albumAddedToQueue;
/// Confirmation when tracks added
/// Snackbar message for tracks added to queue
///
/// In en, this message translates to:
/// **'Tracks added to queue'**
String get tracksAddedToQueue;
/// Confirmation when tracks added
///
/// In en, this message translates to:
/// **'Track added to queue'**
String get trackAddedToQueue;
/// Play button label
///
/// In en, this message translates to:
@@ -1227,6 +1235,30 @@ abstract class S {
/// **'Sort by series order'**
String get sortBySeriesOrder;
/// No description provided for @sortByDuration.
///
/// In en, this message translates to:
/// **'Sort by duration'**
String get sortByDuration;
/// No description provided for @newestFirst.
///
/// In en, this message translates to:
/// **'Newest first'**
String get newestFirst;
/// No description provided for @oldestFirst.
///
/// In en, this message translates to:
/// **'Oldest first'**
String get oldestFirst;
/// No description provided for @viewDetails.
///
/// In en, this message translates to:
/// **'View details'**
String get viewDetails;
/// No description provided for @listView.
///
/// In en, this message translates to:
@@ -1335,6 +1367,12 @@ abstract class S {
/// **'Choose which rows to display on the home screen'**
String get chooseHomeScreenRows;
/// No description provided for @favorite.
///
/// In en, this message translates to:
/// **'Favorite'**
String get favorite;
/// No description provided for @addedToFavorites.
///
/// In en, this message translates to:
@@ -1362,7 +1400,7 @@ abstract class S {
/// No description provided for @addToLibrary.
///
/// In en, this message translates to:
/// **'Add to library'**
/// **'Add'**
String get addToLibrary;
/// No description provided for @unknown.
@@ -1671,12 +1709,24 @@ abstract class S {
/// **'Add album to queue on...'**
String get addAlbumToQueueOn;
/// Add item to queue on selected player
///
/// In en, this message translates to:
/// **'Add to queue'**
String get addToQueue;
/// Bottom sheet title for adding to queue
///
/// In en, this message translates to:
/// **'Add to queue on...'**
String get addToQueueOn;
/// Start radio on selected player
///
/// In en, this message translates to:
/// **'Start radio'**
String get startRadio;
/// Bottom sheet title for starting radio
///
/// In en, this message translates to:
@@ -1833,6 +1883,18 @@ 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:
@@ -2131,27 +2193,29 @@ class _SDelegate extends LocalizationsDelegate<S> {
}
@override
bool isSupported(Locale locale) => <String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
bool isSupported(Locale locale) =>
<String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
@override
bool shouldReload(_SDelegate old) => false;
}
S lookupS(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'de': return SDe();
case 'en': return SEn();
case 'es': return SEs();
case 'fr': return SFr();
case 'de':
return SDe();
case 'en':
return SEn();
case 'es':
return SEs();
case 'fr':
return SFr();
}
throw FlutterError(
'S.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.'
);
'that was used.');
}
+119 -45
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SDe extends S {
String get portHint => 'z.B. 8095 oder leer lassen';
@override
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
String get portDescription =>
'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
@override
String get username => 'Benutzername';
@@ -60,10 +61,12 @@ class SDe extends S {
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
@override
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
String get authServerUrlHint =>
'z.B. auth.example.com (wenn abweichend vom Server)';
@override
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
String get authServerUrlDescription =>
'Leer lassen, wenn Authentifizierung auf demselben Server';
@override
String detectedAuthType(String authType) {
@@ -80,40 +83,49 @@ class SDe extends S {
String get unencryptedConnection => 'Unverschlusselte Verbindung';
@override
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
String get usingHttpOverTailscale =>
'HTTP uber Tailscale (verschlusselter Tunnel)';
@override
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
@override
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
String get httpNotEncrypted =>
'HTTP-Verbindung - Daten sind nicht verschlusselt';
@override
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
String get pleaseEnterServerAddress =>
'Bitte gib deine Music Assistant Serveradresse ein';
@override
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
@override
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
String get pleaseEnterValidPort =>
'Bitte gib eine gultige Portnummer ein (1-65535)';
@override
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
String get pleaseEnterCredentials =>
'Bitte gib Benutzername und Passwort ein';
@override
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
String get authFailed =>
'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
String get maLoginFailed =>
'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
@override
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
String get connectionFailed =>
'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
@override
String get detectingAuth => 'Erkenne Authentifizierung...';
@override
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
String get cannotDetermineAuth =>
'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
@override
String get noAuthentication => 'Keine Authentifizierung';
@@ -221,10 +233,12 @@ class SDe extends S {
String get noRadioStations => 'Keine Radiosender';
@override
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
String get addRadioStationsHint =>
'Füge Radiosender in Music Assistant hinzu';
@override
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
String get searchFailed =>
'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
@override
String get queue => 'Warteschlange';
@@ -359,6 +373,9 @@ class SDe extends S {
@override
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Abspielen';
@@ -471,37 +488,43 @@ class SDe extends S {
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
@override
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
String get tapHeartAudiobook =>
'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
@override
String get noAudiobooks => 'Keine Hörbücher';
@override
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
String get addAudiobooksHint =>
'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
@override
String get noFavoriteArtists => 'Keine Lieblingskünstler';
@override
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
String get tapHeartArtist =>
'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
@override
String get noFavoriteAlbums => 'Keine Lieblingsalben';
@override
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
String get tapHeartAlbum =>
'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
@override
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
@override
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
String get tapHeartPlaylist =>
'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
@override
String get noFavoriteTracks => 'Keine Lieblingssongs';
@override
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
String get longPressTrackHint =>
'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
@override
String get loadSeries => 'Serien laden';
@@ -513,7 +536,8 @@ class SDe extends S {
String get notConnectedTitle => 'Nicht verbunden';
@override
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
String get connectHint =>
'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
@override
String get configureServer => 'Server konfigurieren';
@@ -549,7 +573,8 @@ class SDe extends S {
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
@override
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
String get noDebugLogsYet =>
'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
@override
String get showDebug => 'Debug anzeigen';
@@ -624,6 +649,18 @@ class SDe extends S {
@override
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Listenansicht';
@@ -649,10 +686,12 @@ class SDe extends S {
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@override
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
String get showRandomAudiobooks =>
'Zufällige Hörbücher zum Entdecken anzeigen';
@override
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
String get showRandomSeries =>
'Zufällige Hörbuchserien zum Entdecken anzeigen';
@override
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
@@ -667,16 +706,22 @@ class SDe extends S {
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
String get showFavoriteRadioStations =>
'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
String get extractColorsFromArtwork =>
'Farben aus Album- und Künstlercover extrahieren';
@override
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
String get chooseHomeScreenRows =>
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
@@ -691,7 +736,7 @@ class SDe extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
String get addToLibrary => 'Add';
@override
String get unknown => 'Unbekannt';
@@ -785,13 +830,15 @@ class SDe extends S {
String get theAudioDbApiKey => 'TheAudioDB API-Schlüssel';
@override
String get theAudioDbApiKeyHint => '\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
String get theAudioDbApiKeyHint =>
'\"2\" für kostenlosen Zugang oder Premium-Schlüssel';
@override
String get audiobookLibraries => 'Hörbuch-Bibliotheken';
@override
String get chooseAudiobookLibraries => 'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
String get chooseAudiobookLibraries =>
'Wähle welche Audiobookshelf-Bibliotheken einbezogen werden';
@override
String get unknownLibrary => 'Unbekannte Bibliothek';
@@ -800,7 +847,8 @@ class SDe extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +868,19 @@ class SDe extends S {
}
@override
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
String get noSeriesAvailable =>
'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
@override
String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
String get pullToLoadSeries =>
'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
@override
String get search => 'Suche';
@override
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
String get metadataApisDescription =>
'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
@override
String get lastFmApiKey => 'Last.fm API-Schlüssel';
@@ -854,9 +905,15 @@ class SDe extends S {
@override
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return '$name Radio starten auf...';
@@ -901,19 +958,22 @@ class SDe extends S {
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
@override
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
String get preferLocalPlayerDescription =>
'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
@override
String get smartSortPlayers => 'Intelligente Sortierung';
@override
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
String get smartSortPlayersDescription =>
'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Nicht verfügbar';
@@ -945,7 +1005,15 @@ class SDe extends S {
String get showHints => 'Hinweise anzeigen';
@override
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
String get showHintsDescription =>
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -954,7 +1022,8 @@ class SDe extends S {
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
@@ -972,13 +1041,15 @@ class SDe extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
String get welcomeMessage =>
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
String get dismissPlayerHint =>
'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
@@ -994,19 +1065,22 @@ class SDe extends S {
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
String get addTracksHint =>
'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
String get longPressRadioHint =>
'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
String get longPressPodcastHint =>
'Long-press a podcast and tap the heart to add it to favorites';
@override
String failedToPlayRadioStation(String error) {
+102 -37
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -39,7 +39,7 @@ class SEn extends S {
String get portHint => 'e.g., 8095 or leave blank';
@override
String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
String get portDescription => 'Leave blank unless using a custom port';
@override
String get username => 'Username';
@@ -60,10 +60,12 @@ class SEn extends S {
String get authServerUrlOptional => 'Auth Server URL (Optional)';
@override
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
String get authServerUrlHint =>
'e.g., auth.example.com (if different from server)';
@override
String get authServerUrlDescription => 'Leave empty if authentication is on the same server';
String get authServerUrlDescription =>
'Leave empty if authentication is on the same server';
@override
String detectedAuthType(String authType) {
@@ -80,7 +82,8 @@ class SEn extends S {
String get unencryptedConnection => 'Unencrypted Connection';
@override
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
String get usingHttpOverTailscale =>
'Using HTTP over Tailscale (encrypted tunnel)';
@override
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
@@ -89,31 +92,37 @@ class SEn extends S {
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
@override
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
String get pleaseEnterServerAddress =>
'Please enter your Music Assistant server address';
@override
String get pleaseEnterName => 'Please enter your name';
@override
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
String get pleaseEnterValidPort =>
'Please enter a valid port number (1-65535)';
@override
String get pleaseEnterCredentials => 'Please enter username and password';
@override
String get authFailed => 'Authentication failed. Please check your credentials.';
String get authFailed =>
'Authentication failed. Please check your credentials.';
@override
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
String get maLoginFailed =>
'Music Assistant login failed. Please check your credentials.';
@override
String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
String get connectionFailed =>
'Could not connect to server. Please check the address and try again.';
@override
String get detectingAuth => 'Detecting authentication...';
@override
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
String get cannotDetermineAuth =>
'Cannot determine authentication requirements. Please check server URL.';
@override
String get noAuthentication => 'No Authentication';
@@ -359,6 +368,9 @@ class SEn extends S {
@override
String get tracksAddedToQueue => 'Tracks added to queue';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Play';
@@ -471,37 +483,43 @@ class SEn extends S {
String get noFavoriteAudiobooks => 'No favorite audiobooks';
@override
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
String get tapHeartAudiobook =>
'Tap the heart on an audiobook to add it to favorites';
@override
String get noAudiobooks => 'No audiobooks';
@override
String get addAudiobooksHint => 'Add audiobooks to your library to see them here';
String get addAudiobooksHint =>
'Add audiobooks to your library to see them here';
@override
String get noFavoriteArtists => 'No favorite artists';
@override
String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites';
String get tapHeartArtist =>
'Tap the heart on an artist to add them to favorites';
@override
String get noFavoriteAlbums => 'No favorite albums';
@override
String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites';
String get tapHeartAlbum =>
'Tap the heart on an album to add it to favorites';
@override
String get noFavoritePlaylists => 'No favorite playlists';
@override
String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites';
String get tapHeartPlaylist =>
'Tap the heart on a playlist to add it to favorites';
@override
String get noFavoriteTracks => 'No favorite tracks';
@override
String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites';
String get longPressTrackHint =>
'Long-press a track and tap the heart to add it to favorites';
@override
String get loadSeries => 'Load Series';
@@ -513,7 +531,8 @@ class SEn extends S {
String get notConnectedTitle => 'Not Connected';
@override
String get connectHint => 'Connect to your Music Assistant server to start listening';
String get connectHint =>
'Connect to your Music Assistant server to start listening';
@override
String get configureServer => 'Configure Server';
@@ -624,6 +643,18 @@ class SEn extends S {
@override
String get sortBySeriesOrder => 'Sort by series order';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'List view';
@@ -667,16 +698,22 @@ class SEn extends S {
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
String get showFavoriteRadioStations =>
'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
String get extractColorsFromArtwork =>
'Extract colors from album and artist artwork';
@override
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
String get chooseHomeScreenRows =>
'Choose which rows to display on the home screen';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Added to favorites';
@@ -691,7 +728,7 @@ class SEn extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
String get addToLibrary => 'Add';
@override
String get unknown => 'Unknown';
@@ -791,7 +828,8 @@ class SEn extends S {
String get audiobookLibraries => 'Audiobook Libraries';
@override
String get chooseAudiobookLibraries => 'Choose which Audiobookshelf libraries to include';
String get chooseAudiobookLibraries =>
'Choose which Audiobookshelf libraries to include';
@override
String get unknownLibrary => 'Unknown Library';
@@ -800,7 +838,8 @@ class SEn extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +859,19 @@ class SEn extends S {
}
@override
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
String get noSeriesAvailable =>
'No series available from your audiobook library.\nPull to refresh.';
@override
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
String get pullToLoadSeries =>
'Pull down to load series\nfrom Music Assistant';
@override
String get search => 'Search';
@override
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
String get metadataApisDescription =>
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
@override
String get lastFmApiKey => 'Last.fm API Key';
@@ -854,9 +896,15 @@ class SEn extends S {
@override
String get addAlbumToQueueOn => 'Add album to queue on...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Add to queue on...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Start $name radio on...';
@@ -901,19 +949,22 @@ class SEn extends S {
String get preferLocalPlayer => 'Prefer Local Player';
@override
String get preferLocalPlayerDescription => 'Always select this device first when available';
String get preferLocalPlayerDescription =>
'Always select this device first when available';
@override
String get smartSortPlayers => 'Smart Sort';
@override
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
String get smartSortPlayersDescription =>
'Sort by status (playing, on, off) instead of alphabetically';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Unavailable';
@@ -945,7 +996,15 @@ class SEn extends S {
String get showHints => 'Show Hints';
@override
String get showHintsDescription => 'Display helpful tips for discovering features';
String get showHintsDescription =>
'Display helpful tips for discovering features';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -954,7 +1013,8 @@ class SEn extends S {
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Pull to select players';
@@ -972,13 +1032,15 @@ class SEn extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
String get welcomeMessage =>
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
String get dismissPlayerHint =>
'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
@@ -994,19 +1056,22 @@ class SEn extends S {
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
String get addTracksHint =>
'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
String get longPressRadioHint =>
'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
String get longPressPodcastHint =>
'Long-press a podcast and tap the heart to add it to favorites';
@override
String failedToPlayRadioStation(String error) {
+135 -53
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SEs extends S {
String get portHint => 'ej., 8095 o dejar en blanco';
@override
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
String get portDescription =>
'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
@override
String get username => 'Usuario';
@@ -57,13 +58,16 @@ class SEs extends S {
String get disconnect => 'Desconectar';
@override
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
String get authServerUrlOptional =>
'URL del servidor de autenticación (Opcional)';
@override
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
String get authServerUrlHint =>
'ej., auth.example.com (si es diferente del servidor)';
@override
String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor';
String get authServerUrlDescription =>
'Dejar vacío si la autenticación está en el mismo servidor';
@override
String detectedAuthType(String authType) {
@@ -80,40 +84,49 @@ class SEs extends S {
String get unencryptedConnection => 'Conexión sin cifrar';
@override
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
String get usingHttpOverTailscale =>
'Usando HTTP sobre Tailscale (túnel cifrado)';
@override
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
String get httpsFailedUsingHttp =>
'HTTPS falló, usando HTTP como alternativa';
@override
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
@override
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
String get pleaseEnterServerAddress =>
'Por favor, introduce la dirección de tu servidor Music Assistant';
@override
String get pleaseEnterName => 'Por favor, introduce tu nombre';
@override
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
String get pleaseEnterValidPort =>
'Por favor, introduce un número de puerto válido (1-65535)';
@override
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
String get pleaseEnterCredentials =>
'Por favor, introduce usuario y contraseña';
@override
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
String get authFailed =>
'Autenticación fallida. Por favor, verifica tus credenciales.';
@override
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
String get maLoginFailed =>
'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
@override
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
String get connectionFailed =>
'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
@override
String get detectingAuth => 'Detectando autenticación...';
@override
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
String get cannotDetermineAuth =>
'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
@override
String get noAuthentication => 'Sin autenticación';
@@ -221,10 +234,12 @@ class SEs extends S {
String get noRadioStations => 'No hay emisoras de radio';
@override
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
String get addRadioStationsHint =>
'Añade emisoras de radio en Music Assistant';
@override
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
String get searchFailed =>
'Búsqueda fallida. Por favor, verifica tu conexión.';
@override
String get queue => 'Cola';
@@ -264,7 +279,8 @@ class SEs extends S {
String get system => 'Sistema';
@override
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
String get pullToRefresh =>
'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
@override
String get viewDebugLogs => 'Ver registros de depuración';
@@ -301,7 +317,8 @@ class SEs extends S {
String get logsCleared => 'Registros borrados';
@override
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
String get playerListCopied =>
'Lista de reproductores copiada al portapapeles';
@override
String get noLogsYet => 'Aún no hay registros';
@@ -359,6 +376,9 @@ class SEs extends S {
@override
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Reproducir';
@@ -471,37 +491,43 @@ class SEs extends S {
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
@override
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
String get tapHeartAudiobook =>
'Toca el corazón en un audiolibro para añadirlo a favoritos';
@override
String get noAudiobooks => 'No hay audiolibros';
@override
String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí';
String get addAudiobooksHint =>
'Añade audiolibros a tu biblioteca para verlos aquí';
@override
String get noFavoriteArtists => 'No hay artistas favoritos';
@override
String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos';
String get tapHeartArtist =>
'Toca el corazón en un artista para añadirlo a favoritos';
@override
String get noFavoriteAlbums => 'No hay álbumes favoritos';
@override
String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos';
String get tapHeartAlbum =>
'Toca el corazón en un álbum para añadirlo a favoritos';
@override
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
@override
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
String get tapHeartPlaylist =>
'Toca el corazón en una lista para añadirla a favoritos';
@override
String get noFavoriteTracks => 'No hay canciones favoritas';
@override
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
String get longPressTrackHint =>
'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
@override
String get loadSeries => 'Cargar series';
@@ -513,7 +539,8 @@ class SEs extends S {
String get notConnectedTitle => 'No conectado';
@override
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
String get connectHint =>
'Conéctate a tu servidor Music Assistant para empezar a escuchar';
@override
String get configureServer => 'Configurar servidor';
@@ -549,7 +576,8 @@ class SEs extends S {
String get noLogsToCopy => 'No hay registros para copiar';
@override
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
String get noDebugLogsYet =>
'Aún no hay registros de depuración. Intenta detectar autenticación.';
@override
String get showDebug => 'Mostrar depuración';
@@ -564,7 +592,8 @@ class SEs extends S {
String get noChapters => 'Sin capítulos';
@override
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
String get noChapterInfo =>
'Este audiolibro no tiene información de capítulos';
@override
String errorSeeking(String error) {
@@ -624,6 +653,18 @@ class SEs extends S {
@override
String get sortBySeriesOrder => 'Ordenar por orden de serie';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Vista de lista';
@@ -637,7 +678,8 @@ class SEs extends S {
String get artist => 'Artista';
@override
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
String get showRecentlyPlayedAlbums =>
'Mostrar álbumes reproducidos recientemente';
@override
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
@@ -649,34 +691,44 @@ class SEs extends S {
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@override
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
String get showRandomAudiobooks =>
'Mostrar audiolibros aleatorios para descubrir';
@override
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
String get showRandomSeries =>
'Mostrar series de audiolibros aleatorias para descubrir';
@override
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
@override
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
String get showFavoriteArtists =>
'Mostrar una fila de tus artistas favoritos';
@override
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
String get showFavoriteTracks =>
'Mostrar una fila de tus canciones favoritas';
@override
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
@override
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
String get showFavoriteRadioStations =>
'Show a row of your favorite radio stations';
@override
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
@override
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
String get extractColorsFromArtwork =>
'Extraer colores de las portadas de álbumes y artistas';
@override
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
String get chooseHomeScreenRows =>
'Elige qué filas mostrar en la pantalla de inicio';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Añadido a favoritos';
@@ -691,7 +743,7 @@ class SEs extends S {
String get removedFromLibrary => 'Removed from library';
@override
String get addToLibrary => 'Add to library';
String get addToLibrary => 'Add';
@override
String get unknown => 'Desconocido';
@@ -774,7 +826,8 @@ class SEs extends S {
String get loadingChapters => 'Cargando capítulos...';
@override
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
String get noChapterInfoAvailable =>
'No hay información de capítulos disponible';
@override
String percentComplete(int percent) {
@@ -785,13 +838,15 @@ class SEs extends S {
String get theAudioDbApiKey => 'Clave API de TheAudioDB';
@override
String get theAudioDbApiKeyHint => 'Usa \"2\" para nivel gratuito o clave premium';
String get theAudioDbApiKeyHint =>
'Usa \"2\" para nivel gratuito o clave premium';
@override
String get audiobookLibraries => 'Bibliotecas de audiolibros';
@override
String get chooseAudiobookLibraries => 'Elige qué bibliotecas de Audiobookshelf incluir';
String get chooseAudiobookLibraries =>
'Elige qué bibliotecas de Audiobookshelf incluir';
@override
String get unknownLibrary => 'Biblioteca desconocida';
@@ -800,7 +855,8 @@ class SEs extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +876,19 @@ class SEs extends S {
}
@override
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
String get noSeriesAvailable =>
'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
@override
String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant';
String get pullToLoadSeries =>
'Desliza hacia abajo para cargar series\ndesde Music Assistant';
@override
String get search => 'Buscar';
@override
String get metadataApisDescription => 'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
String get metadataApisDescription =>
'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
@override
String get lastFmApiKey => 'Clave API de Last.fm';
@@ -854,9 +913,15 @@ class SEs extends S {
@override
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Añadir a la cola en...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Iniciar radio de $name en...';
@@ -901,19 +966,22 @@ class SEs extends S {
String get preferLocalPlayer => 'Preferir dispositivo local';
@override
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
String get preferLocalPlayerDescription =>
'Seleccionar siempre este dispositivo primero cuando esté disponible';
@override
String get smartSortPlayers => 'Orden inteligente';
@override
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
String get smartSortPlayersDescription =>
'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'No disponible';
@@ -945,7 +1013,15 @@ class SEs extends S {
String get showHints => 'Mostrar Sugerencias';
@override
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
String get showHintsDescription =>
'Mostrar consejos útiles para descubrir funciones';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -954,7 +1030,8 @@ class SEs extends S {
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
@@ -972,13 +1049,15 @@ class SEs extends S {
String get welcomeToEnsemble => 'Welcome to Ensemble';
@override
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
String get welcomeMessage =>
'By default your phone is the selected player.\nPull the mini player down to select another player.';
@override
String get skip => 'Skip';
@override
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
String get dismissPlayerHint =>
'Swipe down, tap outside, or press back to return';
@override
String playingAlbum(String albumName) {
@@ -994,19 +1073,22 @@ class SEs extends S {
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
String get addTracksHint =>
'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
String get longPressRadioHint =>
'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
String get longPressPodcastHint =>
'Long-press a podcast and tap the heart to add it to favorites';
@override
String failedToPlayRadioStation(String error) {
+152 -61
View File
@@ -1,5 +1,5 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -39,7 +39,8 @@ class SFr extends S {
String get portHint => 'ex., 8095 ou laisser vide';
@override
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
String get portDescription =>
'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
@override
String get username => 'Nom d\'utilisateur';
@@ -60,10 +61,12 @@ class SFr extends S {
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
@override
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
String get authServerUrlHint =>
'ex., auth.example.com (si different du serveur)';
@override
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
String get authServerUrlDescription =>
'Laisser vide si l\'authentification est sur le meme serveur';
@override
String detectedAuthType(String authType) {
@@ -80,40 +83,50 @@ class SFr extends S {
String get unencryptedConnection => 'Connexion non chiffrée';
@override
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
String get usingHttpOverTailscale =>
'Utilisation de HTTP via Tailscale (tunnel chiffre)';
@override
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
String get httpsFailedUsingHttp =>
'HTTPS a échoué, utilisation de HTTP en repli';
@override
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
String get httpNotEncrypted =>
'Connexion HTTP - les donnees ne sont pas chiffrées';
@override
String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant';
String get pleaseEnterServerAddress =>
'Veuillez entrer l\'adresse de votre serveur Music Assistant';
@override
String get pleaseEnterName => 'Veuillez entrer votre nom';
@override
String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)';
String get pleaseEnterValidPort =>
'Veuillez entrer un numero de port valide (1-65535)';
@override
String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe';
String get pleaseEnterCredentials =>
'Veuillez entrer le nom d\'utilisateur et le mot de passe';
@override
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
String get authFailed =>
'Echec de l\'authentification. Veuillez verifier vos identifiants.';
@override
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
String get maLoginFailed =>
'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
@override
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
String get connectionFailed =>
'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
@override
String get detectingAuth => 'Detection de l\'authentification...';
@override
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
String get cannotDetermineAuth =>
'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
@override
String get noAuthentication => 'Pas d\'authentification';
@@ -128,7 +141,8 @@ class SFr extends S {
String get musicAssistantLogin => 'Connexion Music Assistant';
@override
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
String get pressBackToMinimize =>
'Appuyez a nouveau sur retour pour minimiser';
@override
String get recentlyPlayed => 'Ecoutes recemment';
@@ -221,10 +235,12 @@ class SFr extends S {
String get noRadioStations => 'Aucune station de radio';
@override
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
String get addRadioStationsHint =>
'Ajouter des stations de radio dans Music Assistant';
@override
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
String get searchFailed =>
'La recherche a échoué. Veuillez verifier votre connexion.';
@override
String get queue => 'File d\'attente';
@@ -264,7 +280,8 @@ class SFr extends S {
String get system => 'Système';
@override
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
String get pullToRefresh =>
'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
@override
String get viewDebugLogs => 'Voir les journaux de debogage';
@@ -301,7 +318,8 @@ class SFr extends S {
String get logsCleared => 'Journaux effaces';
@override
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
String get playerListCopied =>
'Liste des lecteurs copiee dans le presse-papiers !';
@override
String get noLogsYet => 'Pas encore de journaux';
@@ -359,6 +377,9 @@ class SFr extends S {
@override
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
@override
String get trackAddedToQueue => 'Track added to queue';
@override
String get play => 'Lire';
@@ -471,37 +492,43 @@ class SFr extends S {
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
@override
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
String get tapHeartAudiobook =>
'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
@override
String get noAudiobooks => 'Aucun livre audio';
@override
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
String get addAudiobooksHint =>
'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
@override
String get noFavoriteArtists => 'Aucun artiste favori';
@override
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
String get tapHeartArtist =>
'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
@override
String get noFavoriteAlbums => 'Aucun album favori';
@override
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
String get tapHeartAlbum =>
'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
@override
String get noFavoritePlaylists => 'Aucune playlist favorite';
@override
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
String get tapHeartPlaylist =>
'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
@override
String get noFavoriteTracks => 'Aucune piste favorite';
@override
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
String get longPressTrackHint =>
'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
@override
String get loadSeries => 'Charger les séries';
@@ -513,7 +540,8 @@ class SFr extends S {
String get notConnectedTitle => 'Not Connected';
@override
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
String get connectHint =>
'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
@override
String get configureServer => 'Configurer le serveur';
@@ -549,7 +577,8 @@ class SFr extends S {
String get noLogsToCopy => 'Aucun journal a copier';
@override
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
String get noDebugLogsYet =>
'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
@override
String get showDebug => 'Afficher le debogage';
@@ -564,7 +593,8 @@ class SFr extends S {
String get noChapters => 'Aucun chapitre';
@override
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
String get noChapterInfo =>
'Ce livre audio n\'a pas d\'informations de chapitre';
@override
String errorSeeking(String error) {
@@ -624,6 +654,18 @@ class SFr extends S {
@override
String get sortBySeriesOrder => 'Trier par ordre de série';
@override
String get sortByDuration => 'Sort by duration';
@override
String get newestFirst => 'Newest first';
@override
String get oldestFirst => 'Oldest first';
@override
String get viewDetails => 'View details';
@override
String get listView => 'Vue en liste';
@@ -637,10 +679,12 @@ class SFr extends S {
String get artist => 'Artiste';
@override
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
String get showRecentlyPlayedAlbums =>
'Afficher les albums écoutes recemment';
@override
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
String get showRandomArtists =>
'Afficher des artistes aléatoires a découvrir';
@override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
@@ -649,34 +693,46 @@ class SFr extends S {
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@override
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
String get showRandomAudiobooks =>
'Afficher des livres audio aléatoires a découvrir';
@override
String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir';
String get showRandomSeries =>
'Afficher des séries de livres audio aléatoires a découvrir';
@override
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
@override
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
String get showFavoriteArtists =>
'Afficher une rangee de vos artistes favoris';
@override
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
String get showFavoriteTracks =>
'Afficher une rangee de vos pistes favorites';
@override
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
String get showFavoritePlaylists =>
'Afficher une rangee de vos playlists favorites';
@override
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
String get showFavoriteRadioStations =>
'Afficher une rangee de vos stations de radio favorites';
@override
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
String get showFavoritePodcasts =>
'Afficher une rangee de vos podcasts favoris';
@override
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
String get extractColorsFromArtwork =>
'Extraire les couleurs des pochettes d\'albums et d\'artistes';
@override
String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
String get chooseHomeScreenRows =>
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
@override
String get favorite => 'Favorite';
@override
String get addedToFavorites => 'Ajoute aux favoris';
@@ -721,13 +777,15 @@ class SFr extends S {
String get podcasts => 'Podcasts';
@override
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
String get podcastSupportComingSoon =>
'Prise en charge des podcasts bientot disponible';
@override
String get noPodcasts => 'Aucun podcast';
@override
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
String get addPodcastsHint =>
'Abonnez-vous a des podcasts dans Music Assistant';
@override
String get episodes => 'Episodes';
@@ -774,7 +832,8 @@ class SFr extends S {
String get loadingChapters => 'Chargement des chapitres...';
@override
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
String get noChapterInfoAvailable =>
'Aucune information de chapitre disponible';
@override
String percentComplete(int percent) {
@@ -785,13 +844,15 @@ class SFr extends S {
String get theAudioDbApiKey => 'Cle API TheAudioDB';
@override
String get theAudioDbApiKeyHint => 'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
String get theAudioDbApiKeyHint =>
'Utilisez \"2\" pour le niveau gratuit ou une cle premium';
@override
String get audiobookLibraries => 'Bibliothèques de livres audio';
@override
String get chooseAudiobookLibraries => 'Choisir quelles bibliothèques Audiobookshelf inclure';
String get chooseAudiobookLibraries =>
'Choisir quelles bibliothèques Audiobookshelf inclure';
@override
String get unknownLibrary => 'Bibliothèque inconnue';
@@ -800,7 +861,8 @@ class SFr extends S {
String get musicProviders => 'Music Providers';
@override
String get musicProvidersDescription => 'Choose which accounts to show in your library';
String get musicProvidersDescription =>
'Choose which accounts to show in your library';
@override
String get libraryRefreshing => 'Refreshing library...';
@@ -820,16 +882,19 @@ class SFr extends S {
}
@override
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
String get noSeriesAvailable =>
'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
@override
String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
String get pullToLoadSeries =>
'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
@override
String get search => 'Rechercher';
@override
String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
String get metadataApisDescription =>
'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
@override
String get lastFmApiKey => 'Cle API Last.fm';
@@ -852,11 +917,18 @@ class SFr extends S {
String get playOn => 'Lire sur...';
@override
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
String get addAlbumToQueueOn =>
'Ajouter l\'album a la file d\'attente sur...';
@override
String get addToQueue => 'Add to queue';
@override
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
@override
String get startRadio => 'Start radio';
@override
String startRadioOn(String name) {
return 'Demarrer la radio $name sur...';
@@ -889,7 +961,8 @@ class SFr extends S {
}
@override
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
String get materialYouDescription =>
'Utiliser les couleurs systeme (Android 12+)';
@override
String get accentColor => 'Couleur d\'accentuation';
@@ -901,19 +974,22 @@ class SFr extends S {
String get preferLocalPlayer => 'Préférer le lecteur local';
@override
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
String get preferLocalPlayerDescription =>
'Toujours sélectionner cet appareil en premier si disponible';
@override
String get smartSortPlayers => 'Tri intelligent';
@override
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
String get smartSortPlayersDescription =>
'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
String get disableAutoSwitchDescription =>
'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Indisponible';
@@ -945,7 +1021,15 @@ class SFr extends S {
String get showHints => 'Afficher les astuces';
@override
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
String get showHintsDescription =>
'Afficher des conseils utiles pour découvrir les fonctionnalites';
@override
String get volumePrecisionMode => 'Volume Precision Mode';
@override
String get volumePrecisionModeDescription =>
'Hold still while adjusting volume for fine control';
@override
String get display => 'Display';
@@ -954,7 +1038,8 @@ class SFr extends S {
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
String get showProviderIconsDescription =>
'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
@@ -966,19 +1051,22 @@ class SFr extends S {
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
@override
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
String get selectPlayerHint =>
'Choisissez un lecteur ou fermez en balayant vers le bas';
@override
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
@override
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
String get welcomeMessage =>
'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
@override
String get skip => 'Passer';
@override
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
String get dismissPlayerHint =>
'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
@override
String playingAlbum(String albumName) {
@@ -994,19 +1082,22 @@ class SFr extends S {
String get noTracks => 'No tracks';
@override
String get addTracksHint => 'Add some music to your library to see tracks here';
String get addTracksHint =>
'Add some music to your library to see tracks here';
@override
String get noFavoriteRadioStations => 'No favorite radio stations';
@override
String get longPressRadioHint => 'Long-press a station and tap the heart to add it to favorites';
String get longPressRadioHint =>
'Long-press a station and tap the heart to add it to favorites';
@override
String get noFavoritePodcasts => 'No favorite podcasts';
@override
String get longPressPodcastHint => 'Long-press a podcast and tap the heart to add it to favorites';
String get longPressPodcastHint =>
'Long-press a podcast and tap the heart to add it to favorites';
@override
String failedToPlayRadioStation(String error) {
+30
View File
@@ -137,6 +137,12 @@ 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 {
@@ -165,6 +171,12 @@ 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();
}
@@ -286,6 +298,12 @@ 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();
@@ -332,6 +350,12 @@ 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 {
@@ -528,6 +552,12 @@ class Audiobook extends MediaItem {
String get narratorsString =>
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
/// Check if this audiobook is in the user's library
bool get inLibrary {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
/// Get progress as a percentage (0.0 to 1.0)
double get progress {
if (fullyPlayed == true) return 1.0;
+41 -24
View File
@@ -17,6 +17,7 @@ class Player {
final String? activeSource; // The currently active source for this player
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
final String? activeQueue; // The active_queue from MA - has value when MA is controlling playback
Player({
required this.playerId,
@@ -35,6 +36,7 @@ class Player {
this.activeSource,
this.isExternalSource = false,
this.appId,
this.activeQueue,
});
/// Create a copy of this Player with some fields replaced
@@ -55,6 +57,7 @@ class Player {
String? activeSource,
bool? isExternalSource,
String? appId,
String? activeQueue,
}) {
return Player(
playerId: playerId ?? this.playerId,
@@ -73,6 +76,7 @@ class Player {
activeSource: activeSource ?? this.activeSource,
isExternalSource: isExternalSource ?? this.isExternalSource,
appId: appId ?? this.appId,
activeQueue: activeQueue ?? this.activeQueue,
);
}
@@ -218,50 +222,61 @@ class Player {
// Parse active_source - the currently active source for this player
final activeSource = json['active_source'] as String?;
// Detect external source (optical, Spotify, AirPlay, etc.)
// Primary indicator: app_id - if not 'music_assistant', it's an external source
// Secondary indicators: URI patterns and media_type
// Parse active_queue - this is the key indicator for MA-controlled playback
// When MA is playing: active_queue = 'uuid:...' or similar queue ID
// When external source: active_queue = null
final activeQueue = json['active_queue'] as String?;
// Parse app_id - helps distinguish external sources on DLNA players
final appId = json['app_id'] as String?;
// Detect external source (optical, Spotify Connect, AirPlay, etc.)
//
// Detection logic priority:
// 1. If app_id == 'music_assistant' -> MA is playing, NOT external
// 2. If active_queue has a value -> MA has a queue, NOT external
// (This handles Spotify as music provider where URI is spotify:// but MA controls playback)
// 3. If app_id is set and NOT 'music_assistant' -> external source (e.g., 'http', 'spotify')
// 4. Fallback: Check URI patterns for simple external IDs (optical, line_in, etc.)
bool isExternalSource = false;
// Check app_id first - this is the most reliable indicator for DLNA players
// When MA is playing: app_id = 'music_assistant'
// When external source: app_id = 'http', 'spotify', etc.
final appId = json['app_id'] as String?;
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
// app_id is set but not 'music_assistant' - external source is active
// If MA explicitly says it's playing, trust that
if (appId == 'music_assistant') {
isExternalSource = false;
}
// If there's an active queue, MA is controlling playback (even with spotify:// URIs)
else if (activeQueue != null && activeQueue.isNotEmpty) {
isExternalSource = false;
}
// If app_id is set to something else (http, spotify, etc.), it's external
else if (appId != null && appId.isNotEmpty) {
isExternalSource = true;
}
// Also check current_media for additional external source indicators
if (!isExternalSource && json.containsKey('current_media')) {
// Fallback: check current_media for simple external source indicators
// Only check these when we have no app_id and no active_queue
else if (json.containsKey('current_media')) {
final currentMedia = json['current_media'] as Map<String, dynamic>?;
if (currentMedia != null) {
final uri = currentMedia['uri'] as String?;
final mediaType = currentMedia['media_type'] as String?;
// External source indicators:
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
// 3. Media type is 'unknown' (MA doesn't recognize the source)
// Only check for simple external source identifiers (physical inputs)
// Do NOT flag spotify://, airplay://, etc. as external here -
// those should only be external if app_id indicates external control
if (uri != null) {
final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /)
// These are physical inputs that are always external
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
isExternalSource = isSimpleExternalId;
}
// Also check media_type - 'unknown' often indicates external source
// Also check media_type - 'unknown' with non-MA URI indicates external source
if (!isExternalSource && mediaType == 'unknown') {
// If media_type is unknown and URI doesn't look like MA content, it's external
final uri = currentMedia['uri'] as String?;
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
isExternalSource = true;
@@ -287,6 +302,7 @@ class Player {
activeSource: activeSource,
isExternalSource: isExternalSource,
appId: appId,
activeQueue: activeQueue,
);
}
@@ -308,6 +324,7 @@ class Player {
if (activeSource != null) 'active_source': activeSource,
'is_external_source': isExternalSource,
if (appId != null) 'app_id': appId,
if (activeQueue != null) 'active_queue': activeQueue,
};
}
}
+149 -114
View File
@@ -26,6 +26,7 @@ import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.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.
@@ -64,8 +65,6 @@ 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)
@@ -922,6 +921,7 @@ 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 +1017,11 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<void> _restoreAuthCredentials() async {
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"}');
final storedToken = await SettingsService.getMaAuthToken();
if (storedToken != null) {
_logger.log('🔐 Restoring saved MA token...');
_authManager.setToken(storedToken);
_logger.log('🔐 Auth token restored');
}
}
@@ -1623,6 +1623,41 @@ 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({
@@ -1668,8 +1703,11 @@ 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
DatabaseService.instance.markCachedItemDeleted(mediaType, libraryItemId.toString());
// Use the library ID aware method that handles cases where itemId differs from libraryId
DatabaseService.instance.markCachedItemDeletedByLibraryId(mediaType, libraryItemId.toString());
// Invalidate caches that could show stale inLibrary status
_cacheService.invalidateSearchCache();
if (mediaType == 'album') {
@@ -1790,6 +1828,7 @@ 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');
@@ -2697,21 +2736,31 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('🎙️ Podcast currentMedia: $currentMedia');
}
// Check for external source (optical, Spotify, etc.) - skip caching stale metadata
// 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?;
bool isExternalSource = false;
if (uri != null) {
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) {
final uriLower = uri.toLowerCase();
// Simple external source identifiers (no :// or /)
// Only check for simple external source identifiers (physical inputs)
// Do NOT flag spotify://, etc. - those need app_id/active_queue context
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
uriLower == 'coaxial' || uriLower == 'toslink');
// External streaming protocols
final isExternalProtocol = uriLower.startsWith('spotify://') ||
uriLower.startsWith('airplay://') ||
uriLower.startsWith('cast://') ||
uriLower.startsWith('bluetooth://');
isExternalSource = isSimpleExternalId || isExternalProtocol;
isExternalSource = isSimpleExternalId;
// Also treat unknown media type with non-MA URIs as external
if (!isExternalSource && mediaType == 'unknown' &&
@@ -2721,7 +2770,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
if (isExternalSource) {
_logger.log('📡 External source detected for $playerName (uri: $uri) - skipping metadata cache');
_logger.log('📡 External source detected for $playerName (app_id: $appId, 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
@@ -3165,6 +3214,7 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('⚠️ Failed to refresh tracks: $e');
}
_syncLibraryStatusToService();
notifyListeners();
_logger.log('✅ Force sync complete');
}
@@ -3783,33 +3833,20 @@ class MusicAssistantProvider with ChangeNotifier {
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// 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');
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId, coldStart=$coldStart');
// 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');
}
}
// 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
// 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) {
// Keep currently selected player if still available
if (_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;
@@ -3826,94 +3863,47 @@ 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}');
}
}
}
if (coldStart) {
_logger.log('🚀 Cold start: applying full priority logic');
}
if (playerToSelect == null) {
// 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}');
// Selected player went offline - will fall through to local player
_logger.log('⚠️ Selected player ${_selectedPlayer!.name} no longer 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) {
// 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('🔄 Auto-selected last used player: ${playerToSelect?.name}');
_logger.log('🔄 Restored last selected player: ${playerToSelect.name}');
}
}
// Priority 4: First available player
// 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,
@@ -3921,7 +3911,6 @@ class MusicAssistantProvider with ChangeNotifier {
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
}
selectPlayer(playerToSelect);
}
@@ -4262,16 +4251,16 @@ class MusicAssistantProvider with ChangeNotifier {
if (_api == null) return;
try {
// Log app_id for playing players to help diagnose external source detection
// Log app_id and active_queue 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}, isExternalSource=${player.isExternalSource}');
_logger.log('🔍 Preload ${player.name}: state=${player.state}, app_id=${player.appId}, active_queue=${player.activeQueue != null ? "set" : "null"}, 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})');
_logger.log('🔍 Preload ${player.name}: SKIPPED - external source (app_id=${player.appId}, active_queue=${player.activeQueue != null ? "set" : "null"})');
_cacheService.setCachedTrackForPlayer(player.playerId, null);
return;
}
@@ -4482,6 +4471,7 @@ class MusicAssistantProvider with ChangeNotifier {
currentItemId: rawPlayer.currentItemId,
isExternalSource: rawPlayer.isExternalSource,
appId: rawPlayer.appId,
activeQueue: rawPlayer.activeQueue,
);
_selectedPlayer = updatedPlayer;
@@ -4704,19 +4694,32 @@ class MusicAssistantProvider with ChangeNotifier {
);
}
} else {
// No track data available - show player name placeholder for active players
// No queue data available from API
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
if (_currentTrack != null) {
// 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) {
_currentTrack = null;
stateChanged = true;
// Persist cleared track state
_persistPlaybackState();
}
// Show player name placeholder so notification shows correct player
if (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused') {
// Show player name placeholder only if we have no track data
if (_currentTrack == null && (_selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused')) {
final mediaItem = audio_service.MediaItem(
id: 'player_${_selectedPlayer!.playerId}',
title: _selectedPlayer!.name,
@@ -4738,7 +4741,22 @@ class MusicAssistantProvider with ChangeNotifier {
duration: Duration.zero,
);
}
} else {
} 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') {
audioHandler.clearRemotePlaybackState();
_startIdleServiceTimer();
_positionTracker.clear();
@@ -4832,6 +4850,7 @@ class MusicAssistantProvider with ChangeNotifier {
_albums = syncService.cachedAlbums;
_artists = syncService.cachedArtists;
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
_syncLibraryStatusToService();
notifyListeners();
}
@@ -4849,6 +4868,7 @@ class MusicAssistantProvider with ChangeNotifier {
}
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
// Trigger background sync (non-blocking)
@@ -4875,6 +4895,7 @@ 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);
@@ -4972,6 +4993,7 @@ class MusicAssistantProvider with ChangeNotifier {
SyncService.instance.updateCachedArtists(_artists);
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
@@ -5007,6 +5029,7 @@ class MusicAssistantProvider with ChangeNotifier {
SyncService.instance.updateCachedAlbums(_albums);
_isLoading = false;
_syncLibraryStatusToService();
notifyListeners();
} catch (e) {
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
@@ -5232,6 +5255,18 @@ 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);
+305 -281
View File
@@ -1,6 +1,8 @@
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';
@@ -9,8 +11,12 @@ 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';
@@ -32,15 +38,12 @@ class AlbumDetailsScreen extends StatefulWidget {
State<AlbumDetailsScreen> createState() => _AlbumDetailsScreenState();
}
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin {
class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTickerProviderStateMixin, LibraryStatusMixin {
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
@@ -50,24 +53,42 @@ 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();
_isFavorite = widget.album.favorite ?? false;
_isInLibrary = widget.album.inLibrary;
// 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);
}
_loadTracks();
_loadAlbumDescription();
// 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
// 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
WidgetsBinding.instance.addPostFrameCallback((_) {
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_loadFreshAlbumData();
_extractColors();
}
});
});
@@ -90,8 +111,14 @@ 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();
}
@@ -107,15 +134,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for album: $e');
@@ -124,9 +154,13 @@ 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) {
@@ -172,6 +206,7 @@ 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');
}
@@ -182,11 +217,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
if (success) {
// Optimistically update UI regardless of online/offline state
setState(() {
_isFavorite = newState;
});
completeFavoriteOperation();
// Invalidate home cache so the home screen shows updated favorite status
maProvider.invalidateHomeCache();
@@ -197,14 +228,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
: (newState ? 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(
@@ -220,10 +254,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;
@@ -253,10 +287,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// OPTIMISTIC UPDATE via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -268,20 +300,17 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
// Fire and forget - API call happens in background
maProvider.addToLibrary(
final success = await maProvider.addToLibrary(
mediaType: 'album',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
_logger.log('❌ Failed to add album to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
});
} else {
// Remove from library
int? libraryItemId;
@@ -302,10 +331,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
return;
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// OPTIMISTIC UPDATE via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -316,21 +343,19 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
final success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove album from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
});
}
} catch (e) {
rollbackLibraryOperation();
_logger.log('Error toggling album library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -444,6 +469,89 @@ 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}';
@@ -542,55 +650,15 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
void _addAlbumToQueue() {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// 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);
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addAlbumToQueueOn,
onPlayerSelected: (player) async {
try {
_logger.log('Adding album to queue on ${player.name}');
await maProvider.playTracks(
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
@@ -607,70 +675,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
},
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Show mini player again when sheet closes
GlobalPlayerOverlay.showPlayer();
});
}
void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
// 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);
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
// Add tracks from this index onwards to queue
await maProvider.playTracks(
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
startIndex: index,
clearQueue: false,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -686,17 +704,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
}
},
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
// Slide mini player back up when sheet is dismissed
GlobalPlayerOverlay.showPlayer();
});
}
void _navigateToArtist() {
@@ -993,7 +1000,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [
// Main Play Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -1013,38 +1019,27 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
const SizedBox(width: 12),
// "Play on..." Button (Square)
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : () => _showPlayOnMenu(context),
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
child: Icon(
Symbols.book_2,
size: 25,
fill: isInLibrary ? 1 : 0,
color: isInLibrary
? colorScheme.primary
: Colors.white70,
),
),
const SizedBox(width: 12),
// "Add Album to Queue" Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _isLoading || _tracks.isEmpty ? null : _addAlbumToQueue,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 12),
@@ -1062,22 +1057,40 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite
Icons.favorite,
size: 25,
color: isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Library Button
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleLibrary,
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(
@@ -1085,10 +1098,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
Icons.more_vert,
size: 25,
color: _isLoading || _tracks.isEmpty
? Colors.white70.withOpacity(0.38)
: Colors.white70,
),
),
),
),
@@ -1122,12 +1137,22 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = _tracks[index];
final isExpanded = _expandedTrackIndex == index;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
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,
@@ -1162,98 +1187,28 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
)
: null,
onTap: () {
if (isExpanded) {
// Single tap to collapse when expanded
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Radio button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.radio, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
const SizedBox(width: 10),
// Favorite button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _toggleTrackFavorite(index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
track.favorite == true ? Icons.favorite : Icons.favorite_border,
size: 20,
color: track.favorite == true
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.right,
),
),
],
),
)
: const SizedBox.shrink(),
onTap: () => _playTrack(index),
),
],
);
},
childCount: _tracks.length,
@@ -1309,6 +1264,75 @@ 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;
+424 -193
View File
@@ -1,10 +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_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 'album_details_screen.dart';
import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart';
@@ -15,6 +18,8 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import '../services/library_status_service.dart';
import '../widgets/library_status_builder.dart';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -32,13 +37,11 @@ class ArtistDetailsScreen extends StatefulWidget {
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
}
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryStatusMixin {
final _logger = DebugLogger();
List<Album> _albums = [];
List<Album> _providerAlbums = [];
bool _isLoading = true;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
bool _isDescriptionExpanded = false;
@@ -46,6 +49,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
String? _artistImageUrl;
MusicAssistantProvider? _maProvider;
@override
String get libraryItemKey => LibraryStatusService.makeKey(
'artist',
widget.artist.provider,
widget.artist.itemId,
);
// View preferences
String _sortOrder = 'alpha'; // 'alpha' or 'year'
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
@@ -55,8 +65,15 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
@override
void initState() {
super.initState();
_isFavorite = widget.artist.favorite ?? false;
_isInLibrary = _checkIfInLibrary(widget.artist);
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && _checkIfInLibrary(widget.artist)) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
// Use initial image URL immediately for smooth hero animation
_artistImageUrl = widget.initialImageUrl;
_loadViewPreferences();
@@ -64,8 +81,18 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
_loadArtistDescription();
_refreshFavoriteStatus();
// Defer higher-res image loading and color extraction until after transition
// But hero animation now works because we have initialImageUrl
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
markDetailScreenEntered(context);
// Extract colors from initial image URL if available
if (widget.initialImageUrl != null) {
_extractColors(widget.initialImageUrl!);
}
}
});
// Defer higher-res image loading until after transition
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
@@ -83,12 +110,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
void _onProviderChanged() {
if (!mounted) return;
// Re-check library status when provider data changes
// Re-check library status when provider data changes and sync to service
final newIsInLibrary = _checkIfInLibraryFromProvider();
if (newIsInLibrary != _isInLibrary) {
setState(() {
_isInLibrary = newIsInLibrary;
});
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (newIsInLibrary != service.isInLibrary(key)) {
service.setLibraryStatus(key, newIsInLibrary);
}
}
@@ -126,12 +153,15 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
Future<void> _loadViewPreferences() async {
final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
final viewMode = await SettingsService.getArtistAlbumsViewMode();
// Parallelize settings service calls
final results = await Future.wait([
SettingsService.getArtistAlbumsSortOrder(),
SettingsService.getArtistAlbumsViewMode(),
]);
if (mounted) {
setState(() {
_sortOrder = sortOrder;
_viewMode = viewMode;
_sortOrder = results[0];
_viewMode = results[1];
});
}
}
@@ -198,9 +228,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
try {
final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
if (freshArtist != null && mounted) {
setState(() {
_isFavorite = freshArtist.favorite ?? false;
});
// Sync with centralized service
final service = LibraryStatusService.instance;
final key = libraryItemKey;
final newFavorite = freshArtist.favorite ?? false;
if (service.isFavorite(key) != newFavorite) {
service.setFavoriteStatus(key, newFavorite);
}
}
} catch (e) {
_logger.log('Error refreshing artist favorite status: $e');
@@ -209,9 +243,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
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) {
@@ -254,6 +292,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (libraryItemId == null) {
_logger.log('Error: Could not determine library_item_id for removal');
rollbackFavoriteOperation();
throw Exception('Could not determine library ID for this artist');
}
@@ -264,10 +303,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
if (success) {
setState(() {
_isFavorite = newState;
});
completeFavoriteOperation();
maProvider.invalidateHomeCache();
if (mounted) {
@@ -277,14 +313,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
content: Text(
isOffline
? S.of(context)!.actionQueuedForSync
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
: (newState ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
),
duration: const Duration(seconds: 1),
),
);
}
} else {
rollbackFavoriteOperation();
}
} catch (e) {
rollbackFavoriteOperation();
_logger.log('Error toggling artist favorite: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -306,10 +345,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
/// 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;
@@ -339,10 +378,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -354,21 +391,17 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
// Fire and forget - API call happens in background
maProvider.addToLibrary(
final success = await maProvider.addToLibrary(
mediaType: 'artist',
provider: actualProvider,
itemId: actualItemId,
).catchError((e) {
_logger.log('❌ Failed to add artist to library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
return false;
});
} else {
// Remove from library
int? libraryItemId;
@@ -389,10 +422,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
return;
}
// OPTIMISTIC UPDATE: Update UI immediately
setState(() {
_isInLibrary = newState;
});
// Optimistic update via centralized service
setLibraryStatus(newState);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -403,22 +434,19 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
);
}
// Fire and forget - API call happens in background
maProvider.removeFromLibrary(
final success = await maProvider.removeFromLibrary(
mediaType: 'artist',
libraryItemId: libraryItemId,
).catchError((e) {
_logger.log('❌ Failed to remove artist from library: $e');
// Revert on failure
if (mounted) {
setState(() {
_isInLibrary = !newState;
});
);
if (success) {
completeLibraryOperation();
} else {
rollbackLibraryOperation();
}
return false;
});
}
} catch (e) {
rollbackLibraryOperation();
_logger.log('Error toggling artist library: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -431,7 +459,44 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
void _showRadioMenu(BuildContext context) {
/// Start radio on the selected player directly
void _startRadio(BuildContext context) async {
final maProvider = context.read<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
if (player == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
}
return;
}
try {
await maProvider.playArtistRadio(player.playerId, widget.artist);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
_logger.log('Error starting artist radio: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
void _showRadioOnMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
GlobalPlayerOverlay.showPlayerSelectorForAction(
@@ -462,6 +527,50 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
);
}
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
ListTile(
leading: Icon(Icons.speaker_group_outlined, color: colorScheme.onSurface),
title: Text(S.of(context)!.startRadioOn(widget.artist.name), style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_showRadioOnMenu(context);
},
),
ListTile(
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
title: Text(S.of(context)!.addToQueueOn, style: TextStyle(color: colorScheme.onSurface)),
onTap: () {
Navigator.pop(context);
_showAddToQueueMenu(context);
},
),
const SizedBox(height: 8),
],
),
),
);
}
void _showAddToQueueMenu(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
@@ -519,15 +628,18 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
Future<void> _extractColors(String imageUrl) async {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for artist: $e');
@@ -602,6 +714,133 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
}
}
/// Toggle favorite status for an album
Future<void> _toggleAlbumFavorite(Album album) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !(album.favorite ?? false);
try {
bool success;
if (newState) {
// Add to favorites
String actualProvider = album.provider;
String actualItemId = album.itemId;
if (album.providerMappings != null && album.providerMappings!.isNotEmpty) {
final mapping = album.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => album.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => album.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// Remove from favorites
int? libraryItemId;
if (album.provider == 'library') {
libraryItemId = int.tryParse(album.itemId);
} else if (album.providerMappings != null) {
final libraryMapping = album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromFavorites(
mediaType: 'album',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success) {
_loadArtistAlbums();
}
} catch (e) {
_logger.log('Error toggling album favorite: $e');
}
}
/// Toggle library status for an album
Future<void> _toggleAlbumLibrary(Album album) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !album.inLibrary;
try {
bool success;
if (newState) {
// Add to library
String? actualProvider;
String? actualItemId;
if (album.providerMappings != null && album.providerMappings!.isNotEmpty) {
final mapping = album.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => album.providerMappings!.first,
);
if (mapping.providerInstance != 'library') {
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
}
if (actualProvider != null && actualItemId != null) {
success = await maProvider.addToLibrary(
mediaType: 'album',
itemId: actualItemId,
provider: actualProvider,
);
} else {
success = false;
}
} else {
// Remove from library
int? libraryItemId;
if (album.provider == 'library') {
libraryItemId = int.tryParse(album.itemId);
} else if (album.providerMappings != null) {
final libraryMapping = album.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => album.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromLibrary(
mediaType: 'album',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success) {
_loadArtistAlbums();
}
} catch (e) {
_logger.log('Error toggling album library: $e');
}
}
@override
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
@@ -748,11 +987,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
children: [
// Main Radio Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
onPressed: () => _showRadioMenu(context),
onPressed: () => _startRadio(context),
icon: const Icon(Icons.radio),
label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom(
@@ -768,47 +1006,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
const SizedBox(width: 12),
// "Add to Queue" Button (Square)
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showAddToQueueMenu(context),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 12),
// Favorite Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: _toggleFavorite,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
// Library Button
SizedBox(
height: 50,
@@ -822,10 +1019,79 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
color: _isInLibrary
Symbols.book_2,
size: 25,
fill: isInLibrary ? 1 : 0,
color: isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
: 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: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.artist,
item: widget.artist,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
sortOrder: _sortOrder,
onToggleSort: _toggleSortOrder,
viewMode: _viewMode,
onCycleView: _cycleViewMode,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
),
),
),
@@ -855,57 +1121,18 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
)
else ...[
// Library Albums Section with inline controls
// Library Albums Section
if (_albums.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Row(
children: [
Text(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0),
child: Text(
S.of(context)!.inLibrary,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
),
),
),
_buildAlbumSliver(_albums),
@@ -915,54 +1142,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (_providerAlbums.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(24.0, _albums.isEmpty ? 8.0 : 24.0, 12.0, 8.0),
child: Row(
children: [
Text(
padding: EdgeInsets.fromLTRB(24.0, _albums.isEmpty ? 8.0 : 24.0, 24.0, 8.0),
child: Text(
S.of(context)!.fromProviders,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
// Only show controls here if no library albums
if (_albums.isEmpty) ...[
const Spacer(),
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
],
),
),
),
_buildAlbumSliver(_providerAlbums),
@@ -985,7 +1172,28 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
final textTheme = Theme.of(context).textTheme;
const String heroSuffix = 'artist_albums';
return InkWell(
// 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.album,
item: album,
isFavorite: album.favorite ?? false,
isInLibrary: album.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAlbumFavorite(album),
onToggleLibrary: () => _toggleAlbumLibrary(album),
);
},
child: InkWell(
onTap: () {
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
@@ -1079,6 +1287,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
],
),
),
);
}
@@ -1121,7 +1330,28 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return ListTile(
// 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.album,
item: album,
isFavorite: album.favorite ?? false,
isInLibrary: album.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAlbumFavorite(album),
onToggleLibrary: () => _toggleAlbumLibrary(album),
);
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -1176,6 +1406,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
);
},
),
);
}
}
+239 -44
View File
@@ -1,13 +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 '../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';
@@ -33,6 +36,7 @@ class AudiobookAuthorScreen extends StatefulWidget {
}
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@@ -116,6 +120,149 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
}
}
/// Toggle favorite status for an audiobook
Future<void> _toggleAudiobookFavorite(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !(book.favorite ?? false);
try {
bool success;
if (newState) {
// Add to favorites
String actualProvider = book.provider;
String actualItemId = book.itemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.firstWhere(
(m) => m.available,
orElse: () => book.providerMappings!.first,
),
);
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
success = await maProvider.addToFavorites(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
// Remove from favorites
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromFavorites(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success && mounted) {
setState(() {
final index = _audiobooks.indexWhere((b) => b.itemId == book.itemId);
if (index != -1) {
_audiobooks[index] = Audiobook(
itemId: book.itemId,
provider: book.provider,
name: book.name,
sortName: book.sortName,
uri: book.uri,
providerMappings: book.providerMappings,
favorite: newState,
metadata: book.metadata,
year: book.year,
);
}
});
}
} catch (e) {
_logger.log('Error toggling audiobook favorite: $e');
}
}
/// Toggle library status for an audiobook
Future<void> _toggleAudiobookLibrary(Audiobook book) async {
final maProvider = context.read<MusicAssistantProvider>();
final newState = !book.inLibrary;
try {
bool success;
if (newState) {
// Add to library
String? actualProvider;
String? actualItemId;
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
final mapping = book.providerMappings!.firstWhere(
(m) => m.available && m.providerInstance != 'library',
orElse: () => book.providerMappings!.first,
);
if (mapping.providerInstance != 'library') {
actualProvider = mapping.providerInstance;
actualItemId = mapping.itemId;
}
}
if (actualProvider != null && actualItemId != null) {
success = await maProvider.addToLibrary(
mediaType: 'audiobook',
itemId: actualItemId,
provider: actualProvider,
);
} else {
success = false;
}
} else {
// Remove from library
int? libraryItemId;
if (book.provider == 'library') {
libraryItemId = int.tryParse(book.itemId);
} else if (book.providerMappings != null) {
final libraryMapping = book.providerMappings!.firstWhere(
(m) => m.providerInstance == 'library',
orElse: () => book.providerMappings!.first,
);
if (libraryMapping.providerInstance == 'library') {
libraryItemId = int.tryParse(libraryMapping.itemId);
}
}
if (libraryItemId != null) {
success = await maProvider.removeFromLibrary(
mediaType: 'audiobook',
libraryItemId: libraryItemId,
);
} else {
success = false;
}
}
if (success) {
// Refresh would require reloading from parent - for now just show success
_logger.log('Audiobook library status toggled');
}
} catch (e) {
_logger.log('Error toggling audiobook library: $e');
}
}
@override
Widget build(BuildContext context) {
final adaptiveTheme = context.select<ThemeProvider, bool>(
@@ -168,6 +315,49 @@ 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,
@@ -242,56 +432,17 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
),
),
// Audiobooks Section Header with controls
// Audiobooks Section Header
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
child: Row(
children: [
Text(
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,
),
),
const Spacer(),
// Sort toggle
IconButton(
icon: Icon(
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// View mode toggle
IconButton(
icon: Icon(
_viewMode == 'list'
? Icons.view_list
: _viewMode == 'grid3'
? Icons.grid_view
: Icons.grid_on,
color: colorScheme.primary,
size: 20,
),
tooltip: _viewMode == 'grid2'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
),
_buildAudiobookSliver(),
@@ -344,7 +495,28 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
return InkWell(
// 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,
@@ -440,6 +612,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
],
),
),
);
}
@@ -450,7 +623,28 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final textTheme = Theme.of(context).textTheme;
final heroSuffix = 'author${_heroTagSuffix}';
return ListTile(
// Get adaptive scheme for context menu
final themeProvider = context.read<ThemeProvider>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final adaptiveScheme = themeProvider.adaptiveTheme
? (isDark ? _darkColorScheme : _lightColorScheme)
: null;
return GestureDetector(
onLongPressStart: (details) {
MediaContextMenu.show(
context: context,
position: details.globalPosition,
mediaType: ContextMenuMediaType.audiobook,
item: book,
isFavorite: book.favorite ?? false,
isInLibrary: book.inLibrary,
adaptiveColorScheme: adaptiveScheme,
onToggleFavorite: () => _toggleAudiobookFavorite(book),
onToggleLibrary: () => _toggleAudiobookLibrary(book),
);
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: Hero(
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
@@ -534,6 +728,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
)
: null,
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
),
);
}
+155 -13
View File
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../widgets/media_context_menu.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -35,6 +38,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final _logger = DebugLogger();
bool _isDescriptionExpanded = false;
bool _isFavorite = false;
bool _isInLibrary = false;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
int? _expandedChapterIndex;
@@ -46,17 +50,24 @@ 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((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
// Load full audiobook details with chapters
_loadAudiobookDetails();
});
@@ -107,15 +118,18 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for audiobook: $e');
@@ -209,6 +223,88 @@ 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>();
@@ -652,7 +748,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [
// Play/Resume Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -686,26 +781,33 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.replay),
child: const Icon(Icons.replay, size: 25),
),
),
],
const SizedBox(width: 12),
// Play On Button
// Library Button
SizedBox(
height: 50,
width: 50,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context),
onPressed: _toggleLibrary,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined),
child: Icon(
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: Colors.white70,
),
),
),
@@ -724,10 +826,50 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
MediaContextMenu.show(
context: context,
position: position,
mediaType: ContextMenuMediaType.audiobook,
item: widget.audiobook,
isFavorite: _isFavorite,
isInLibrary: _isInLibrary,
onToggleFavorite: _toggleFavorite,
onToggleLibrary: _toggleLibrary,
adaptiveColorScheme: adaptiveScheme,
showTopRow: false,
);
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
Icons.more_vert,
size: 25,
color: Colors.white70,
),
),
),
),
+2 -1
View File
@@ -271,7 +271,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.watch<MusicAssistantProvider>();
// Use read since we only need the provider for non-reactive operations (getImageUrl)
final maProvider = context.read<MusicAssistantProvider>();
final l10n = S.of(context)!;
return Scaffold(
+4 -3
View File
@@ -43,7 +43,6 @@ 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;
@@ -66,11 +65,13 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
),
centerTitle: true,
),
body: _buildPlaylistsList(context, provider),
body: _buildPlaylistsList(context),
);
}
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
Widget _buildPlaylistsList(BuildContext context) {
// Use read since we only need the provider for non-reactive operations
final provider = context.read<MusicAssistantProvider>();
final colorScheme = Theme.of(context).colorScheme;
// Show cached data immediately if available, even while loading
+16 -11
View File
@@ -11,7 +11,6 @@ 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;
@@ -34,35 +33,41 @@ class LibraryTracksScreen extends StatelessWidget {
),
centerTitle: true,
),
body: _buildTracksList(context, provider),
body: _buildTracksList(context),
);
}
Widget _buildTracksList(
BuildContext context, MusicAssistantProvider 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),
);
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 (provider.tracks.isEmpty && provider.isLoading) {
if (tracks.isEmpty && isLoading) {
return Center(
child: CircularProgressIndicator(color: colorScheme.primary),
);
}
if (provider.tracks.isEmpty) {
return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
if (tracks.isEmpty) {
return EmptyState.tracks(
context: context,
onRefresh: context.read<MusicAssistantProvider>().loadLibrary,
);
}
return ListView.builder(
itemCount: provider.tracks.length,
itemCount: 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 = provider.tracks[index];
return _buildTrackTile(context, track, provider, index);
final track = tracks[index] as Track;
return _buildTrackTile(context, track, index);
},
);
}
@@ -70,9 +75,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;
+78 -620
View File
@@ -4,7 +4,6 @@ import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
import '../services/database_service.dart';
import '../services/profile_service.dart';
import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart';
import '../l10n/app_localizations.dart';
@@ -19,260 +18,91 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _serverUrlController = TextEditingController();
final TextEditingController _ownerNameController = TextEditingController();
final TextEditingController _portController = TextEditingController();
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final FocusNode _usernameFocusNode = FocusNode();
final _logger = DebugLogger();
bool _isConnecting = false;
bool _isDetectingAuth = false;
AuthStrategy? _detectedAuthStrategy;
String? _detectedAuthType;
String? _error;
bool _showDebug = false;
List<String> _debugLogs = [];
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
@override
void initState() {
super.initState();
_loadSavedSettings();
}
Future<void> _loadSavedSettings() async {
final savedPort = await SettingsService.getWebSocketPort();
final savedAuthServerUrl = await SettingsService.getAuthServerUrl();
final savedOwnerName = await SettingsService.getOwnerName();
if (savedPort != null) {
setState(() {
_portController.text = savedPort.toString();
});
}
if (savedAuthServerUrl != null) {
setState(() {
_authServerUrlController.text = savedAuthServerUrl;
});
}
if (savedOwnerName != null) {
setState(() {
_ownerNameController.text = savedOwnerName;
});
}
}
@override
void dispose() {
_serverUrlController.dispose();
_ownerNameController.dispose();
_portController.dispose();
_authServerUrlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_usernameFocusNode.dispose();
super.dispose();
}
String _normalizeServerUrl(String url) {
// Remove any trailing slashes
url = url.trim();
/// Normalize server URL and add default port for local IPs
String _buildServerUrl(String input) {
var url = input.trim();
// Remove trailing slashes
while (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
// If URL already has a protocol, return it
if (url.startsWith('http://') || url.startsWith('https://')) {
// Add protocol if missing
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (_isLocalAddress(url)) {
url = 'http://$url';
} else if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
// Tailscale - use http since VPN is encrypted
url = 'http://$url';
} else {
url = 'https://$url';
}
}
// Add default port 8095 for local IPs without a port
final uri = Uri.parse(url);
if (!uri.hasPort && _isLocalAddress(uri.host)) {
return '${uri.scheme}://${uri.host}:8095';
}
return url;
}
// If it's an IP address or localhost, default to http://
if (url.startsWith('192.') ||
url.startsWith('10.') ||
url.startsWith('172.') ||
url == 'localhost' ||
url.startsWith('127.')) {
return 'http://$url';
}
// Tailscale URLs (*.ts.net) - default to http:// since VPN tunnel is already encrypted
if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
return 'http://$url';
}
// For domain names, default to https://
return 'https://$url';
}
/// Check if the URL is using HTTP (for security warning display)
bool _isHttpConnection(String url) {
final normalized = _normalizeServerUrl(url);
return normalized.startsWith('http://') && !normalized.startsWith('https://');
}
/// Check if the URL is a Tailscale URL (safe for HTTP)
bool _isTailscaleUrl(String url) {
final normalized = url.trim().toLowerCase();
return normalized.contains('.ts.net');
}
String _buildServerUrl() {
// Normalize the base URL
var url = _normalizeServerUrl(_serverUrlController.text.trim());
// Parse to check if port is already included
final uri = Uri.parse(url);
// Get port from field
final portText = _portController.text.trim();
if (portText.isEmpty) {
return url; // No port specified, use URL as-is
}
final port = int.tryParse(portText);
if (port == null) {
return url; // Invalid port, use URL as-is
}
// Skip adding port if it's the default for the scheme
final isDefaultPort = (uri.scheme == 'https' && port == 443) ||
(uri.scheme == 'http' && port == 80);
if (isDefaultPort) {
return url; // Don't add default ports
}
// Build URL with custom port
return '${uri.scheme}://${uri.host}:$port';
}
void _addDebugLog(String message) {
setState(() {
_debugLogs.add('[${DateTime.now().toString().substring(11, 19)}] $message');
});
}
Future<void> _detectAuthRequirements() async {
if (_serverUrlController.text.trim().isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterServerAddress;
});
return;
}
setState(() {
_isDetectingAuth = true;
_error = null;
_detectedAuthStrategy = null;
_detectedAuthType = null;
_resolvedServerUrl = null;
_usedHttpFallback = false;
_debugLogs.clear();
});
try {
final serverUrl = _buildServerUrl();
_addDebugLog('Built server URL: $serverUrl');
final provider = context.read<MusicAssistantProvider>();
_addDebugLog('Starting auth detection...');
// Auto-detect authentication requirements
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
// Check if auth manager used a different URL (e.g., HTTP fallback)
final resolvedUrl = provider.authManager.lastSuccessfulUrl;
if (resolvedUrl != null && resolvedUrl != serverUrl) {
_addDebugLog('URL resolved via fallback: $resolvedUrl');
_usedHttpFallback = serverUrl.startsWith('https://') && resolvedUrl.startsWith('http://');
}
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
if (strategy == null) {
setState(() {
_error = S.of(context)!.cannotDetermineAuth;
_isDetectingAuth = false;
});
return;
}
setState(() {
_detectedAuthStrategy = strategy;
_detectedAuthType = _getAuthTypeName(strategy.name);
_resolvedServerUrl = resolvedUrl;
_isDetectingAuth = false;
});
// If no auth required, connect immediately
if (strategy.name == 'none') {
await _connect();
} else {
// Auth required - focus username field after a brief delay
// to allow the new fields to build
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_usernameFocusNode.requestFocus();
}
});
}
} catch (e) {
setState(() {
_error = 'Auth detection failed: ${e.toString()}';
_isDetectingAuth = false;
});
}
}
String _getAuthTypeName(String strategyName) {
switch (strategyName) {
case 'none':
return S.of(context)!.noAuthentication;
case 'basic':
return S.of(context)!.httpBasicAuth;
case 'authelia':
return S.of(context)!.authelia;
case 'music_assistant':
return S.of(context)!.musicAssistantLogin;
default:
return 'Unknown';
}
}
IconData _getAuthIcon(String strategyName) {
switch (strategyName) {
case 'none':
return Icons.lock_open_rounded;
case 'basic':
return Icons.vpn_key_rounded;
case 'authelia':
return Icons.shield_rounded;
case 'music_assistant':
return Icons.music_note_rounded;
default:
return Icons.security_rounded;
}
/// Check if address is local/private
bool _isLocalAddress(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.');
}
Future<void> _connect() async {
if (_serverUrlController.text.trim().isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterServerAddress;
});
final serverInput = _serverUrlController.text.trim();
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
// Validate inputs
if (serverInput.isEmpty) {
setState(() => _error = S.of(context)!.pleaseEnterServerAddress);
return;
}
// Validate owner name - only required if NOT using MA auth
// (MA auth will get display_name from user profile after login)
final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant';
if (!isMaAuth && _ownerNameController.text.trim().isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterName;
});
if (username.isEmpty) {
setState(() => _error = S.of(context)!.pleaseEnterCredentials);
_usernameFocusNode.requestFocus();
return;
}
if (password.isEmpty) {
setState(() => _error = S.of(context)!.pleaseEnterCredentials);
return;
}
@@ -282,129 +112,28 @@ class _LoginScreenState extends State<LoginScreen> {
});
try {
// Use the resolved URL if available (e.g., from HTTP fallback), otherwise build it
final serverUrl = _resolvedServerUrl ?? _buildServerUrl();
_addDebugLog('Using server URL for connection: $serverUrl');
final port = _portController.text.trim();
// Validate port (if provided)
int? portNum;
if (port.isNotEmpty) {
portNum = int.tryParse(port);
if (portNum == null || portNum < 1 || portNum > 65535) {
setState(() {
_error = S.of(context)!.pleaseEnterValidPort;
_isConnecting = false;
});
return;
}
}
// Save owner name (if provided) and port to settings
// For MA auth, the owner name will be set from user profile after login
if (!isMaAuth && _ownerNameController.text.trim().isNotEmpty) {
final ownerName = _ownerNameController.text.trim();
await SettingsService.setOwnerName(ownerName);
// Create/activate profile in local database for manual name entry
if (DatabaseService.instance.isInitialized) {
await ProfileService.instance.onManualNameEntered(ownerName);
}
}
await SettingsService.setWebSocketPort(portNum);
final serverUrl = _buildServerUrl(serverInput);
_logger.log('Connecting to: $serverUrl');
final provider = context.read<MusicAssistantProvider>();
// Handle authentication based on detected strategy
// Note: isMaAuth is already defined earlier in this method
if (_detectedAuthStrategy != null && _detectedAuthStrategy!.name != 'none' && !isMaAuth) {
// Pre-connect auth (Authelia, Basic Auth)
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
if (username.isEmpty || password.isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false;
});
return;
}
// Get auth server URL (if provided for Authelia)
String? authServerUrl;
if (_detectedAuthStrategy!.name == 'authelia') {
final authUrl = _authServerUrlController.text.trim();
if (authUrl.isNotEmpty) {
authServerUrl = _normalizeServerUrl(authUrl);
}
}
// Attempt login with detected strategy
final success = await provider.authManager.login(
serverUrl,
username,
password,
_detectedAuthStrategy!,
authServerUrl: authServerUrl,
);
if (!success) {
setState(() {
_error = S.of(context)!.authFailed;
_isConnecting = false;
});
return;
}
// Save credentials for future auto-login
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
// Save auth server URL if provided
if (authServerUrl != null && authServerUrl.isNotEmpty) {
await SettingsService.setAuthServerUrl(authServerUrl);
} else {
await SettingsService.setAuthServerUrl(null);
}
// Save auth credentials to settings
final serialized = provider.authManager.serializeCredentials();
if (serialized != null) {
await SettingsService.setAuthCredentials(serialized);
}
}
// Connect to server
await provider.connectToServer(serverUrl);
// Post-connect auth (Music Assistant native auth)
if (isMaAuth) {
final username = _usernameController.text.trim();
final password = _passwordController.text.trim();
if (username.isEmpty || password.isEmpty) {
setState(() {
_error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false;
});
return;
}
_addDebugLog('Authenticating with Music Assistant...');
// Authenticate with MA
_logger.log('Authenticating with Music Assistant...');
// Check if we have a stored MA token first
final storedToken = await SettingsService.getMaAuthToken();
bool authSuccess = false;
if (storedToken != null && provider.api != null) {
_addDebugLog('Trying stored MA token...');
_logger.log('Trying stored MA token...');
authSuccess = await provider.api!.authenticateWithToken(storedToken);
}
if (!authSuccess) {
_addDebugLog('Logging in with credentials...');
// Login with credentials over WebSocket
_logger.log('Logging in with credentials...');
final accessToken = await provider.api?.loginWithCredentials(username, password);
if (accessToken == null) {
@@ -421,34 +150,35 @@ class _LoginScreenState extends State<LoginScreen> {
// Save the token for future auto-login
await SettingsService.setMaAuthToken(tokenToStore);
_addDebugLog('MA token saved for future logins');
_logger.log('MA token saved for future logins');
}
// Save credentials for future auto-login
// Save credentials
await SettingsService.setUsername(username);
await SettingsService.setPassword(password);
await SettingsService.setServerUrl(serverUrl);
// Save auth strategy info
// Save auth credentials to settings
await SettingsService.setAuthCredentials({
'strategy': 'music_assistant',
'data': {'username': username},
});
// Set owner name from username for profile
if (DatabaseService.instance.isInitialized) {
await ProfileService.instance.onManualNameEntered(username);
}
// Wait for connection and authentication to complete
// MA auth happens asynchronously after connect, so we need to wait longer
// Wait for connection to complete
for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(milliseconds: 500));
if (provider.isConnected) break;
}
if (provider.isConnected) {
// For first-time users, wait for player selection so the welcome
// overlay can appear immediately without home screen flash.
// This matches the logic in AppStartup._checkAndConnect().
// First-time user: wait for player selection
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
if (!hasCompletedOnboarding) {
// First-time user: wait for connection + player selection
for (int i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 250));
if (provider.selectedPlayer != null) break;
@@ -457,7 +187,6 @@ class _LoginScreenState extends State<LoginScreen> {
// Navigate to home screen
if (mounted) {
// Ensure keyboard is closed before navigating
FocusScope.of(context).unfocus();
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomeScreen()),
@@ -470,6 +199,7 @@ class _LoginScreenState extends State<LoginScreen> {
});
}
} catch (e) {
_logger.log('Connection error: $e');
setState(() {
_error = 'Connection failed: ${e.toString()}';
_isConnecting = false;
@@ -482,9 +212,6 @@ class _LoginScreenState extends State<LoginScreen> {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// Determine if we need auth fields
final needsAuth = _detectedAuthStrategy != null && _detectedAuthStrategy!.name != 'none';
return Scaffold(
backgroundColor: colorScheme.background,
body: SafeArea(
@@ -538,257 +265,14 @@ class _LoginScreenState extends State<LoginScreen> {
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting && !_isDetectingAuth,
enabled: !_isConnecting,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 24),
// Your Name - only show AFTER auth detection AND only for non-MA auth
// (MA auth gets display_name from user profile automatically)
// Show for: 'none' (no auth), 'authelia', 'basic_auth', etc.
// Hide for: 'music_assistant' (gets name from profile)
if (_detectedAuthStrategy != null &&
_detectedAuthStrategy!.name != 'music_assistant') ...[
Text(
S.of(context)!.yourName,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
TextField(
controller: _ownerNameController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.yourFirstName,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.person_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting && !_isDetectingAuth,
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 24),
],
// Port
Text(
S.of(context)!.portOptional,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.portDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 12),
TextField(
controller: _portController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.portHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.settings_ethernet_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting && !_isDetectingAuth,
keyboardType: TextInputType.number,
textInputAction: needsAuth ? TextInputAction.next : TextInputAction.done,
onSubmitted: (_) => needsAuth ? null : _detectedAuthStrategy == null ? _detectAuthRequirements() : _connect(),
),
const SizedBox(height: 24),
// Detected auth method indicator
if (_detectedAuthType != null)
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: needsAuth
? colorScheme.primaryContainer.withOpacity(0.3)
: colorScheme.tertiaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
_getAuthIcon(_detectedAuthStrategy!.name),
color: needsAuth ? colorScheme.primary : colorScheme.tertiary,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.detectedAuthType(_detectedAuthType!),
style: TextStyle(
color: needsAuth
? colorScheme.onPrimaryContainer
: colorScheme.onTertiaryContainer,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
if (needsAuth)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
S.of(context)!.serverRequiresAuth,
style: TextStyle(
color: colorScheme.onPrimaryContainer.withOpacity(0.7),
fontSize: 12,
),
),
),
],
),
),
],
),
),
// HTTP connection info banner
if (_resolvedServerUrl != null && _isHttpConnection(_resolvedServerUrl!))
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.secondaryContainer.withOpacity(0.3)
: colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: _isTailscaleUrl(_serverUrlController.text)
? null
: Border.all(color: colorScheme.error.withOpacity(0.3)),
),
child: Row(
children: [
Icon(
_isTailscaleUrl(_serverUrlController.text)
? Icons.vpn_lock_rounded
: Icons.warning_amber_rounded,
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.secondary
: colorScheme.error,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isTailscaleUrl(_serverUrlController.text)
? S.of(context)!.tailscaleVpnConnection
: S.of(context)!.unencryptedConnection,
style: TextStyle(
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
: colorScheme.onErrorContainer,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_isTailscaleUrl(_serverUrlController.text)
? S.of(context)!.usingHttpOverTailscale
: _usedHttpFallback
? S.of(context)!.httpsFailedUsingHttp
: S.of(context)!.httpNotEncrypted,
style: TextStyle(
color: (_isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
: colorScheme.onErrorContainer).withOpacity(0.7),
fontSize: 12,
),
),
),
],
),
),
],
),
),
// Authentication fields (shown only if auth required)
if (needsAuth) ...[
// Auth Server URL (only for Authelia)
if (_detectedAuthStrategy?.name == 'authelia') ...[
Text(
S.of(context)!.authServerUrlOptional,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.authServerUrlDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 12),
TextField(
controller: _authServerUrlController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: S.of(context)!.authServerUrlHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.shield_rounded,
color: colorScheme.onSurface.withOpacity(0.54),
),
),
enabled: !_isConnecting,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 24),
],
// Username
Text(
S.of(context)!.username,
style: textTheme.titleMedium?.copyWith(
@@ -820,8 +304,9 @@ class _LoginScreenState extends State<LoginScreen> {
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
const SizedBox(height: 24),
// Password
Text(
S.of(context)!.password,
style: textTheme.titleMedium?.copyWith(
@@ -854,10 +339,7 @@ class _LoginScreenState extends State<LoginScreen> {
onSubmitted: (_) => _connect(),
),
const SizedBox(height: 16),
],
const SizedBox(height: 16),
const SizedBox(height: 32),
// Error message
if (_error != null)
@@ -893,16 +375,14 @@ class _LoginScreenState extends State<LoginScreen> {
if (_showDebug) ...[
const SizedBox(height: 16),
DebugConsole(
onClear: () => setState(() => _debugLogs.clear()),
onClear: () {},
),
const SizedBox(height: 16),
],
// Connect button (changes based on state)
// Connect button
ElevatedButton(
onPressed: (_isConnecting || _isDetectingAuth)
? null
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
onPressed: _isConnecting ? null : _connect,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
@@ -912,29 +392,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
elevation: 0,
),
child: _isDetectingAuth
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
),
),
const SizedBox(width: 12),
Text(
S.of(context)!.detectingAuth,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
)
: _isConnecting
child: _isConnecting
? SizedBox(
height: 20,
width: 20,
@@ -944,7 +402,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
)
: Text(
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
S.of(context)!.connect,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
File diff suppressed because it is too large Load Diff
+319 -232
View File
@@ -1,4 +1,5 @@
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';
@@ -10,6 +11,8 @@ 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';
@@ -41,9 +44,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}' : '';
@@ -55,16 +58,16 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
void initState() {
super.initState();
_isFavorite = widget.playlist.favorite ?? false;
_isInLibrary = widget.playlist.inLibrary;
_loadTracks();
// Defer color extraction until after hero animation completes
// Mark that we're on a detail screen and extract colors immediately
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
});
}
Future<void> _extractColors() async {
@@ -74,15 +77,18 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
if (imageUrl == null) return;
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && mounted) {
setState(() {
_lightColorScheme = colorSchemes.$1;
_darkColorScheme = colorSchemes.$2;
});
// Update ThemeProvider so nav bar uses adaptive colors
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
}
} catch (e) {
_logger.log('Failed to extract colors for playlist: $e');
@@ -227,6 +233,86 @@ 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;
@@ -324,6 +410,89 @@ 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;
@@ -387,56 +556,86 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
);
}
void _addPlaylistToQueue() {
void _showMoreMenu(BuildContext context, ColorScheme colorScheme) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
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)),
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Spacing.vGap16,
Text(
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
const SizedBox(height: 8),
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
Spacing.vGap16,
if (players.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: players.length,
itemBuilder: (context, playerIndex) {
final player = players[playerIndex];
return ListTile(
leading: Icon(
Icons.speaker,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(player.name),
onTap: () async {
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),
],
),
),
);
}
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.playTracks(
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
startIndex: 0,
clearQueue: false,
);
_logger.log('Playlist added to queue on ${player.name}');
if (mounted) {
@@ -453,67 +652,19 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
}
},
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
void _addTrackToQueue(BuildContext context, int index) {
final maProvider = context.read<MusicAssistantProvider>();
final players = maProvider.availablePlayers;
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);
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: S.of(context)!.addToQueueOn,
onPlayerSelected: (player) async {
try {
await maProvider.playTracks(
await maProvider.addTracksToQueue(
player.playerId,
_tracks,
startIndex: index,
clearQueue: false,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -529,16 +680,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
}
},
);
},
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
],
),
),
).whenComplete(() {
GlobalPlayerOverlay.showPlayer();
});
}
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
@@ -782,7 +923,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
children: [
// Main Play Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -802,42 +942,6 @@ 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,
@@ -851,10 +955,53 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
),
child: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
Icons.favorite,
size: 25,
color: _isFavorite
? colorScheme.error
: colorScheme.onSurfaceVariant,
: Colors.white70,
),
),
),
const SizedBox(width: 12),
// Three-dot Menu Button
SizedBox(
height: 50,
width: 50,
child: Builder(
builder: (buttonContext) => FilledButton.tonal(
onPressed: () {
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,
),
),
),
),
@@ -888,13 +1035,23 @@ 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 Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
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: [
@@ -958,98 +1115,28 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: track.duration != null
? Text(
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
)
: null,
onTap: () {
if (isExpanded) {
setState(() {
_expandedTrackIndex = null;
});
} else {
_playTrack(index);
}
},
onLongPress: () {
setState(() {
_expandedTrackIndex = isExpanded ? null : index;
});
},
),
// Expandable action buttons
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: isExpanded
? Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Radio button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayRadioMenu(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.radio, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addTrackToQueue(context, index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
const SizedBox(width: 10),
// Favorite button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _toggleTrackFavorite(index),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.xxl),
),
),
child: Icon(
track.favorite == true ? Icons.favorite : Icons.favorite_border,
size: 20,
color: track.favorite == true
? colorScheme.error
: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.right,
),
),
],
),
)
: const SizedBox.shrink(),
onTap: () => _playTrack(index),
),
],
);
},
childCount: _tracks.length,
+287 -105
View File
@@ -1,16 +1,23 @@
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;
@@ -36,7 +43,8 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
bool _isLoadingEpisodes = false;
bool _isDescriptionExpanded = false;
bool _isInLibrary = false;
String? _expandedEpisodeId; // Track which episode is expanded for actions
bool _isFavorite = false;
String _sortOrder = 'newest'; // 'newest', 'oldest', 'alpha', 'duration'
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@@ -44,16 +52,109 @@ 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((_) {
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted) {
_extractColors();
markDetailScreenEntered(context);
_extractColors(); // Extract colors immediately - async so won't block
}
});
_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;
@@ -186,6 +287,71 @@ 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
@@ -193,14 +359,17 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
if (imageUrl != null) {
try {
final colorSchemes = await PaletteHelper.extractColorSchemes(
CachedNetworkImageProvider(imageUrl),
);
// Use isolate-based extraction to avoid blocking the main thread
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (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');
@@ -261,6 +430,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
}
setState(() {
_episodes = episodes;
_sortEpisodes();
});
}
}
@@ -406,6 +576,35 @@ 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
@@ -569,7 +768,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
children: [
// Main Play Latest Episode Button
Expanded(
flex: 2,
child: SizedBox(
height: 50,
child: ElevatedButton.icon(
@@ -591,24 +789,6 @@ 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,
@@ -622,10 +802,76 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
),
child: Icon(
_isInLibrary ? Icons.library_add_check : Icons.library_add,
Symbols.book_2,
size: 25,
fill: _isInLibrary ? 1 : 0,
color: _isInLibrary
? colorScheme.primary
: colorScheme.onSurfaceVariant,
: 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,
),
),
),
),
@@ -732,29 +978,28 @@ 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 Column(
children: [
InkWell(
return GestureDetector(
onTap: () => _playEpisode(episode),
onLongPress: () {
setState(() {
_expandedEpisodeId = isExpanded ? null : episodeId;
});
},
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 (bigger than before)
ClipRRect(
// Episode cover
Hero(
tag: HeroTags.podcastEpisodeCover + (episode.uri ?? episode.itemId),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 72,
@@ -784,6 +1029,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
@@ -802,7 +1048,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
const SizedBox(height: 4),
Text(
episodeDescription,
maxLines: isExpanded ? 100 : 2,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
@@ -852,70 +1098,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
],
),
),
),
// Expanded action buttons (matching album track style)
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
child: Row(
children: [
// Play button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _playEpisode(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.play_arrow, size: 20),
),
),
const SizedBox(width: 10),
// Play on... button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _showPlayOnMenu(context, episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.speaker_group_outlined, size: 20),
),
),
const SizedBox(width: 10),
// Add to queue button
SizedBox(
height: 44,
width: 44,
child: FilledButton.tonal(
onPressed: () => _addToQueue(episode),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Icon(Icons.playlist_add, size: 20),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
);
},
childCount: _episodes.length,
@@ -0,0 +1,504 @@
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),
),
],
);
},
),
),
);
}
}
+6 -5
View File
@@ -132,8 +132,8 @@ class _QueueScreenState extends State<QueueScreen> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
// Use select to only rebuild when selectedPlayer changes
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.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(maProvider),
onPressed: () => _handleTransferQueue(context.read<MusicAssistantProvider>()),
),
IconButton(
icon: const Icon(Icons.refresh),
@@ -161,11 +161,12 @@ class _QueueScreenState extends State<QueueScreen> {
),
],
),
body: _buildBody(maProvider),
body: _buildBody(context),
);
}
Widget _buildBody(MusicAssistantProvider maProvider) {
Widget _buildBody(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
File diff suppressed because it is too large Load Diff
+11 -69
View File
@@ -7,6 +7,7 @@ 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';
@@ -39,9 +40,7 @@ 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;
@@ -85,9 +84,7 @@ 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
@@ -111,9 +108,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
@@ -267,7 +262,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final provider = context.watch<MusicAssistantProvider>();
// Use select to only rebuild when connectionState changes
final connectionState = context.select<MusicAssistantProvider, MAConnectionState>((p) => p.connectionState);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -295,9 +291,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DebugLogScreen(),
),
FadeSlidePageRoute(child: const DebugLogScreen()),
);
},
color: colorScheme.onBackground,
@@ -345,15 +339,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_getStatusIcon(provider.connectionState),
color: _getStatusColor(provider.connectionState, colorScheme),
_getStatusIcon(connectionState),
color: _getStatusColor(connectionState, colorScheme),
size: 20,
),
const SizedBox(width: 8),
Text(
_getStatusText(provider.connectionState),
_getStatusText(connectionState),
style: textTheme.titleMedium?.copyWith(
color: _getStatusColor(provider.connectionState, colorScheme),
color: _getStatusColor(connectionState, colorScheme),
fontWeight: FontWeight.bold,
),
),
@@ -703,33 +697,6 @@ 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(
@@ -754,7 +721,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -763,36 +730,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: SwitchListTile(
title: Text(
S.of(context)!.disableAutoSwitch,
S.of(context)!.volumePrecisionMode,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
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',
S.of(context)!.volumePrecisionModeDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _volumePrecisionMode,
+119 -401
View File
@@ -1,468 +1,186 @@
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';
/// Central authentication manager
/// Manages auth strategy selection, auto-detection, and credential lifecycle
/// Simple authentication manager for Music Assistant
/// MA now uses its own built-in authentication system
class AuthManager {
final _logger = DebugLogger();
// Available auth strategies
final List<AuthStrategy> _strategies = [
NoAuthStrategy(),
BasicAuthStrategy(),
AutheliaStrategy(),
MusicAssistantAuthStrategy(),
];
String? _accessToken;
String? _longLivedToken;
AuthStrategy? _currentStrategy;
AuthCredentials? _currentCredentials;
/// Get the best available token (prefer long-lived)
String? get token => _longLivedToken ?? _accessToken;
/// Get current auth strategy (null if none selected)
AuthStrategy? get currentStrategy => _currentStrategy;
/// Check if we have valid credentials
bool get hasCredentials => token != null;
/// Get current credentials (null if none stored)
AuthCredentials? get currentCredentials => _currentCredentials;
/// Auto-detect authentication requirements for a server
/// Returns null if server is unreachable or auth method unknown
/// Also returns the final URL used (in case of HTTP fallback) via the callback
String? _lastSuccessfulUrl;
String? get lastSuccessfulUrl => _lastSuccessfulUrl;
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
_lastSuccessfulUrl = null;
// Normalize URL
var baseUrl = _normalizeUrl(serverUrl);
_logger.log('Normalized URL: $baseUrl');
// Try detection with the initial URL
var result = await _tryDetectAuth(baseUrl);
// If HTTPS failed and we haven't tried HTTP yet, try HTTP fallback
if (result == null && baseUrl.startsWith('https://')) {
final httpUrl = baseUrl.replaceFirst('https://', 'http://');
_logger.log('🔄 HTTPS failed, trying HTTP fallback: $httpUrl');
result = await _tryDetectAuth(httpUrl);
if (result != null) {
baseUrl = httpUrl;
}
}
if (result != null) {
_lastSuccessfulUrl = baseUrl;
_logger.log('✅ Auth detection successful with URL: $baseUrl');
}
return result;
}
/// Normalize a server URL with appropriate protocol
String _normalizeUrl(String url) {
var baseUrl = url;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Use http for local IPs, https for domains
if (baseUrl.startsWith('192.') ||
baseUrl.startsWith('10.') ||
baseUrl.startsWith('172.') ||
baseUrl == 'localhost' ||
baseUrl.startsWith('127.')) {
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';
}
}
return baseUrl;
}
/// 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)
/// Login to Music Assistant and get an access token
Future<String?> login(String serverUrl, String username, String password) async {
try {
_logger.log('Probing server for auth headers...');
// Test root endpoint
final probeUrl = baseUrl;
_logger.log('Probe URL: $probeUrl');
final apiUrl = _buildApiUrl(serverUrl);
_logger.log('🔐 Logging in to Music Assistant at $apiUrl');
final response = await http.get(
Uri.parse(probeUrl),
).timeout(const Duration(seconds: 10));
_logger.log('Probe status: ${response.statusCode}');
// Check for redirect (common with reverse proxy auth)
if (response.statusCode == 302 || response.statusCode == 307) {
final location = response.headers['location'];
_logger.log('Redirect to: $location');
// If redirects to Authelia login, it's Authelia
if (location != null && location.contains('authelia')) {
_logger.log('✓ Detected Authelia (redirect)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
}
// Server returned 401 Unauthorized - check WWW-Authenticate header
if (response.statusCode == 401) {
final wwwAuth = response.headers['www-authenticate']?.toLowerCase();
_logger.log('401 with WWW-Authenticate: $wwwAuth');
// Check for Basic Auth
if (wwwAuth != null && wwwAuth.contains('basic')) {
_logger.log('✓ Detected Basic Authentication');
return _strategies.firstWhere((s) => s.name == 'basic');
}
// Check for Bearer token - could be MA native auth
if (wwwAuth != null && wwwAuth.contains('bearer')) {
_logger.log('✓ Detected Bearer auth - likely Music Assistant native');
return _strategies.firstWhere((s) => s.name == 'music_assistant');
}
}
// If we got here and there's any auth challenge, assume Authelia
// (since we're behind a reverse proxy that requires auth)
if (response.statusCode >= 300) {
_logger.log('Server requires authentication (status ${response.statusCode})');
_logger.log('Assuming Authelia (default for authenticated reverse proxy)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
} catch (e) {
_logger.log('✗ Auth detection error: $e');
return null; // Return null to trigger fallback
}
_logger.log('⚠️ Could not determine auth method');
return null;
}
/// Check if server is a Music Assistant server with native auth
/// Returns 'required' if auth needed, 'none' if no auth, null if not MA or error
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
try {
final uri = Uri.parse(baseUrl);
// Use the port from URL if specified, otherwise use default ports (80/443)
// Don't default to 8095 here - that's only for direct connections without reverse proxy
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api',
);
_logger.log('Checking MA API at: $apiUrl');
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
final response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
'command': 'auth/login',
'args': {
'username': username,
'password': password,
},
}),
).timeout(const Duration(seconds: 5));
}
).timeout(const Duration(seconds: 10));
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
// 401/403 means auth required - check this first
if (response.statusCode == 401 || response.statusCode == 403) {
_logger.log('MA API returned ${response.statusCode} - auth required');
return 'required';
}
// Also check for "Authentication required" text response
if (response.body.toLowerCase().contains('authentication required')) {
_logger.log('MA API returned auth required message');
return 'required';
}
_logger.log('Auth response status: ${response.statusCode}');
if (response.statusCode == 200) {
// 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');
}
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
return null;
}
final result = data['result'] as Map<String, dynamic>?;
_accessToken = result?['access_token'] as String?;
if (_accessToken != null) {
_logger.log('✓ Got access token from MA');
// Try to create a long-lived token
_longLivedToken = await _createLongLivedToken(apiUrl, _accessToken!);
return _longLivedToken ?? _accessToken;
}
_logger.log('✗ No access token in response');
return null;
}
_logger.log('✗ Authentication failed: ${response.statusCode}');
return null;
} catch (e) {
_logger.log('MA auth check error: $e');
_logger.log('✗ Login error: $e');
return null;
}
}
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
/// Create a long-lived token for persistent authentication
Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
try {
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
_logger.log('Creating long-lived token...');
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
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 (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;
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// 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
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;
}
}
client.close();
}
_logger.log('⚠️ Could not create long-lived token (non-fatal)');
return null;
} catch (e) {
client.close();
rethrow;
_logger.log('⚠️ Long-lived token creation failed: $e (non-fatal)');
return null;
}
} 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');
/// Validate a stored token
Future<bool> validateToken(String serverUrl, String storedToken) async {
try {
final apiUrl = _buildApiUrl(serverUrl);
// 200 = no auth required
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (statusCode == 200) {
final response = await http.post(
apiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $storedToken',
},
body: jsonEncode({
'command': 'server/info',
}),
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (!data.containsKey('error_code')) {
_longLivedToken = storedToken;
return true;
}
}
return false;
} catch (e) {
_logger.log('No-auth error: $e');
_logger.log('Token validation failed: $e');
return false;
}
}
/// 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
/// Build headers for WebSocket connection
Map<String, dynamic> getWebSocketHeaders() {
if (_currentStrategy == null || _currentCredentials == null) {
if (token != null) {
return {'Authorization': 'Bearer $token'};
}
return {};
}
return _currentStrategy!.buildWebSocketHeaders(_currentCredentials!);
}
/// Get HTTP streaming headers for current credentials
/// Build headers for HTTP streaming requests
Map<String, String> getStreamingHeaders() {
if (_currentStrategy == null || _currentCredentials == null) {
if (token != null) {
return {'Authorization': 'Bearer $token'};
}
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!),
};
}
/// 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
/// Clear authentication state
void logout() {
_currentStrategy = null;
_currentCredentials = null;
_accessToken = null;
_longLivedToken = null;
_logger.log('Logged out - cleared auth state');
}
/// Get strategy by name (for manual selection)
AuthStrategy? getStrategyByName(String name) {
try {
return _strategies.firstWhere((s) => s.name == name);
} catch (e) {
return null;
/// Build API URL from server URL
Uri _buildApiUrl(String serverUrl) {
var baseUrl = serverUrl;
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
baseUrl = 'http://$baseUrl';
}
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
@@ -1,42 +0,0 @@
/// 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
@@ -1,172 +0,0 @@
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;
}
}
@@ -1,82 +0,0 @@
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
@@ -1,254 +0,0 @@
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
@@ -1,64 +0,0 @@
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,6 +193,33 @@ 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.
+1 -1
View File
@@ -12,7 +12,7 @@ class HardwareVolumeService {
factory HardwareVolumeService() => _instance;
HardwareVolumeService._internal();
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons');
static const _channel = MethodChannel('com.collotsspot.ensemble/volume_buttons');
final _logger = DebugLogger();
final _volumeUpController = StreamController<void>.broadcast();
+177
View File
@@ -0,0 +1,177 @@
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,
};
}
+31
View File
@@ -2076,6 +2076,37 @@ 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 {
+12 -10
View File
@@ -90,9 +90,7 @@ 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';
@@ -152,6 +150,7 @@ 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';
@@ -354,18 +353,10 @@ 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) {
@@ -692,6 +683,17 @@ 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();
+74
View File
@@ -596,4 +596,78 @@ 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();
}
}
}
+2 -2
View File
@@ -68,7 +68,7 @@ class AppTheme {
foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground),
),
cardTheme: CardTheme(
cardTheme: CardThemeData(
color: scheme.surface,
elevation: 2,
),
@@ -91,7 +91,7 @@ class AppTheme {
foregroundColor: scheme.onBackground,
iconTheme: IconThemeData(color: scheme.onBackground),
),
cardTheme: CardTheme(
cardTheme: CardThemeData(
color: scheme.surface,
elevation: 2,
),
+470 -49
View File
@@ -1,9 +1,25 @@
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)
@@ -26,15 +42,398 @@ class AdaptiveColors {
);
}
/// Helper to get contrasting text color based on background luminance
/// 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
Color getContrastingTextColor(Color backgroundColor) {
// 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;
// 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;
}
}
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(
@@ -48,7 +447,7 @@ class PaletteHelper {
}
}
/// Extract actual colors from the palette (not seed-based)
/// Extract actual colors from the palette using HCT color space
static AdaptiveColors? extractAdaptiveColors(PaletteGenerator? palette, {required bool isDark}) {
if (palette == null) return null;
@@ -58,22 +457,9 @@ class PaletteHelper {
palette.dominantColor?.color ??
const Color(0xFF604CEC);
// 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();
}
// Use HCT for perceptually accurate color adjustment
// Ensure minimum chroma (colorfulness) so colors aren't muddy
Color adjustedPrimary = _ensureMinChroma(primary);
if (isDark) {
// Dark mode: use dark muted colors for background
@@ -81,18 +467,19 @@ class PaletteHelper {
palette.mutedColor?.color ??
const Color(0xFF121212);
// 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();
// 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());
// 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();
final Color miniPlayer = Color(Hct.from(
surfaceHct.hue,
(surfaceHct.chroma * 1.2).clamp(4, 16),
30,
).toInt());
return AdaptiveColors(
primary: adjustedPrimary,
@@ -106,16 +493,18 @@ class PaletteHelper {
palette.mutedColor?.color ??
Colors.white;
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
final Color surface = hslSurface
.withLightness((hslSurface.lightness).clamp(0.92, 0.98))
.toColor();
final surfaceHct = Hct.fromInt(surfaceBase.value);
final Color surface = Color(Hct.from(
surfaceHct.hue,
math.min(surfaceHct.chroma, _minSurfaceChroma),
95,
).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();
final Color miniPlayer = Color(Hct.from(
surfaceHct.hue,
(surfaceHct.chroma * 1.2).clamp(4, 16),
75,
).toInt());
return AdaptiveColors(
primary: adjustedPrimary,
@@ -126,7 +515,7 @@ class PaletteHelper {
}
}
/// Generate color schemes from a palette (kept for backward compatibility)
/// Generate color schemes from a palette using HCT color space
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
if (palette == null) return null;
@@ -135,18 +524,47 @@ class PaletteHelper {
if (lightColors == null || darkColors == null) return null;
// Determine contrasting text colors based on primary color luminance
final lightOnPrimary = getContrastingTextColor(lightColors.primary);
final darkOnPrimary = getContrastingTextColor(darkColors.primary);
// 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);
final lightOnMiniPlayer = getContrastingTextColor(lightColors.miniPlayer);
final darkOnMiniPlayer = getContrastingTextColor(darkColors.miniPlayer);
final lightScheme = ColorScheme(
brightness: Brightness.light,
primary: lightColors.primary,
primary: lightAdjustedPrimary,
onPrimary: lightOnPrimary,
secondary: lightColors.primary.withOpacity(0.8),
secondary: lightAdjustedPrimary.withOpacity(0.8),
onSecondary: lightOnPrimary,
secondaryContainer: lightSecondaryContainer,
onSecondaryContainer: Colors.black87,
error: Colors.red,
onError: Colors.white,
surface: lightColors.surface,
@@ -157,10 +575,12 @@ class PaletteHelper {
final darkScheme = ColorScheme(
brightness: Brightness.dark,
primary: darkColors.primary,
primary: darkAdjustedPrimary,
onPrimary: darkOnPrimary,
secondary: darkColors.primary.withOpacity(0.8),
secondary: darkAdjustedPrimary.withOpacity(0.8),
onSecondary: darkOnPrimary,
secondaryContainer: darkSecondaryContainer,
onSecondaryContainer: Colors.white,
error: Colors.redAccent,
onError: Colors.black,
surface: darkColors.surface,
@@ -173,6 +593,7 @@ 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);
+18 -2
View File
@@ -1,6 +1,5 @@
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';
@@ -10,11 +9,13 @@ 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 {
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
// Use isolate-based extraction for better performance
final colorSchemes = await PaletteHelper.extractColorSchemesFromUrl(imageUrl);
if (colorSchemes != null && context.mounted) {
final themeProvider = context.read<ThemeProvider>();
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
@@ -32,6 +33,13 @@ 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;
@@ -203,4 +211,12 @@ 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();
}
}
}
+211 -3
View File
@@ -1,5 +1,6 @@
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';
@@ -10,7 +11,11 @@ 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;
@@ -32,7 +37,7 @@ class AlbumCard extends StatefulWidget {
State<AlbumCard> createState() => _AlbumCardState();
}
class _AlbumCardState extends State<AlbumCard> {
class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
String? _fallbackImageUrl;
bool _triedFallback = false;
bool _maImageFailed = false;
@@ -44,9 +49,25 @@ class _AlbumCardState extends State<AlbumCard> {
/// 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();
}
@@ -102,6 +123,176 @@ class _AlbumCardState extends State<AlbumCard> {
}
}
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>();
@@ -125,8 +316,13 @@ class _AlbumCardState extends State<AlbumCard> {
if (_isNavigating) return;
_isNavigating = true;
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
// 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);
}
Navigator.push(
context,
FadeSlidePageRoute(
@@ -143,6 +339,18 @@ class _AlbumCardState extends State<AlbumCard> {
});
});
},
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: [
+2 -1
View File
@@ -1,6 +1,7 @@
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';
@@ -105,7 +106,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
if (_albums.isEmpty) {
return Center(
child: Text(
'No albums found',
S.of(context)!.noAlbumsFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+204 -2
View File
@@ -1,5 +1,6 @@
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';
@@ -7,10 +8,13 @@ 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;
@@ -32,7 +36,7 @@ class ArtistCard extends StatefulWidget {
State<ArtistCard> createState() => _ArtistCardState();
}
class _ArtistCardState extends State<ArtistCard> {
class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
static final _logger = DebugLogger();
String? _fallbackImageUrl;
bool _triedFallback = false;
@@ -45,9 +49,25 @@ class _ArtistCardState extends State<ArtistCard> {
/// 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();
}
@@ -94,6 +114,176 @@ class _ArtistCardState extends State<ArtistCard> {
}
}
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>();
@@ -135,6 +325,18 @@ class _ArtistCardState extends State<ArtistCard> {
});
});
},
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: [
+2 -1
View File
@@ -1,6 +1,7 @@
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';
@@ -105,7 +106,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
if (_artists.isEmpty) {
return Center(
child: Text(
'No artists found',
S.of(context)!.noArtistsFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+226 -14
View File
@@ -1,14 +1,19 @@
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;
@@ -94,7 +99,6 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
@@ -111,7 +115,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
if (_audiobooks.isEmpty) {
return Center(
child: Text(
'No audiobooks found',
S.of(context)!.noAudiobooks,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
@@ -189,7 +193,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
}
}
class _AudiobookCard extends StatelessWidget {
class _AudiobookCard extends StatefulWidget {
final Audiobook audiobook;
final String heroTagSuffix;
final MusicAssistantProvider maProvider;
@@ -200,11 +204,207 @@ class _AudiobookCard extends StatelessWidget {
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 = maProvider.getImageUrl(audiobook, size: 256);
final imageUrl = widget.maProvider.getImageUrl(widget.audiobook, size: 256);
return GestureDetector(
onTap: () {
@@ -212,13 +412,25 @@ class _AudiobookCard extends StatelessWidget {
context,
FadeSlidePageRoute(
child: AudiobookDetailScreen(
audiobook: audiobook,
heroTagSuffix: heroTagSuffix,
audiobook: widget.audiobook,
heroTagSuffix: widget.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: [
@@ -226,7 +438,7 @@ class _AudiobookCard extends StatelessWidget {
child: Stack(
children: [
Hero(
tag: HeroTags.audiobookCover + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix',
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + '_${widget.heroTagSuffix}',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
@@ -268,7 +480,7 @@ class _AudiobookCard extends StatelessWidget {
),
),
// Progress indicator
if (audiobook.progress > 0)
if (widget.audiobook.progress > 0)
Positioned(
bottom: 0,
left: 0,
@@ -279,7 +491,7 @@ class _AudiobookCard extends StatelessWidget {
bottomRight: Radius.circular(8),
),
child: LinearProgressIndicator(
value: audiobook.progress,
value: widget.audiobook.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 4,
@@ -287,20 +499,20 @@ class _AudiobookCard extends StatelessWidget {
),
),
// Provider icon overlay
if (audiobook.providerMappings?.isNotEmpty == true)
if (widget.audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: audiobook.providerMappings!.first.providerDomain,
domain: widget.audiobook.providerMappings!.first.providerDomain,
),
],
),
),
const SizedBox(height: 8),
Hero(
tag: HeroTags.audiobookTitle + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix',
tag: HeroTags.audiobookTitle + (widget.audiobook.uri ?? widget.audiobook.itemId) + '_${widget.heroTagSuffix}',
child: Material(
color: Colors.transparent,
child: Text(
audiobook.name,
widget.audiobook.name,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
@@ -310,7 +522,7 @@ class _AudiobookCard extends StatelessWidget {
),
),
Text(
audiobook.authorsString,
widget.audiobook.authorsString,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
+143 -27
View File
@@ -121,6 +121,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
StreamSubscription<Map<String, dynamic>>? _queueItemsSubscription;
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
bool _isSeeking = false; // Debounce flag to prevent rapid-fire seeks
// Pre-computed static colors to avoid object creation during animation frames
static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3)
@@ -2860,33 +2861,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
children: [
SizedBox(
height: 48, // Increase touch target height
// PERF Phase 1: Use cached SliderThemeData
child: SliderTheme(
data: _sliderTheme,
child: Slider(
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(),
max: currentTrack.duration!.inSeconds.toDouble(),
onChanged: (value) => _seekPositionNotifier.value = value,
onChangeStart: (value) => _seekPositionNotifier.value = value,
onChangeEnd: (value) async {
try {
await maProvider.seek(selectedPlayer.playerId, value.round());
await Future.delayed(const Duration(milliseconds: 200));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
if (mounted) {
_seekPositionNotifier.value = null;
}
}
},
activeColor: primaryColor,
inactiveColor: primaryColor20, // PERF: Use cached color
),
child: _buildProgressSlider(
context,
currentProgress,
currentTrack.duration!.inSeconds.toDouble(),
primaryColor,
primaryColor20,
maProvider,
selectedPlayer.playerId,
),
),
Padding(
@@ -4132,6 +4114,140 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
);
}
/// Custom progress slider that doesn't require Overlay widget
/// (Flutter 3.38's Material 3 Slider uses OverlayPortal which fails in our overlay context)
Widget _buildProgressSlider(
BuildContext context,
double currentProgress,
double maxProgress,
Color activeColor,
Color inactiveColor,
MusicAssistantProvider maProvider,
String playerId,
) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final progress = maxProgress > 0 ? (currentProgress / maxProgress).clamp(0.0, 1.0) : 0.0;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) {
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
_seekPositionNotifier.value = newProgress * maxProgress;
},
onHorizontalDragUpdate: (details) {
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
_seekPositionNotifier.value = newProgress * maxProgress;
},
onHorizontalDragCancel: () {
_seekPositionNotifier.value = null;
},
onHorizontalDragEnd: (details) async {
final seekPosition = _seekPositionNotifier.value;
if (seekPosition != null && !_isSeeking) {
_isSeeking = true;
try {
await maProvider.seek(playerId, seekPosition.round());
// Wait for server to process seek before allowing another
await Future.delayed(const Duration(milliseconds: 1500));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
_isSeeking = false;
if (mounted) {
_seekPositionNotifier.value = null;
}
}
}
},
onTapUp: (details) async {
if (_isSeeking) return; // Ignore if already seeking
// Calculate position from tap location
final newProgress = (details.localPosition.dx / width).clamp(0.0, 1.0);
final seekPosition = newProgress * maxProgress;
_seekPositionNotifier.value = seekPosition;
_isSeeking = true;
try {
await maProvider.seek(playerId, seekPosition.round());
// Wait for server to process seek before allowing another
await Future.delayed(const Duration(milliseconds: 1500));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
_isSeeking = false;
if (mounted) {
_seekPositionNotifier.value = null;
}
}
},
child: SizedBox(
height: 48,
child: Center(
child: SizedBox(
height: 20,
child: Stack(
clipBehavior: Clip.none,
children: [
// Inactive track (full width, thinner)
Positioned(
left: 0,
right: 0,
top: 8.5,
child: Container(
height: 3,
decoration: BoxDecoration(
color: inactiveColor,
borderRadius: BorderRadius.circular(1.5),
),
),
),
// Active track
Positioned(
left: 0,
top: 7,
child: Container(
width: width * progress,
height: 6,
decoration: BoxDecoration(
color: activeColor,
borderRadius: BorderRadius.circular(3),
),
),
),
// Thumb
Positioned(
left: (width * progress) - 7,
top: 3,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: activeColor,
shape: BoxShape.circle,
),
),
),
],
),
),
),
),
);
},
);
}
}
/// Represents a single line of synced lyrics with timestamp
+5 -2
View File
@@ -3,6 +3,7 @@ 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';
@@ -611,6 +612,8 @@ 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) {
@@ -632,8 +635,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
icon: const Icon(Symbols.book_2, fill: 0),
activeIcon: const Icon(Symbols.book_2, fill: 1),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
+114
View File
@@ -0,0 +1,114 @@
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
@@ -0,0 +1,166 @@
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
+6 -9
View File
@@ -14,9 +14,10 @@ class PlayerSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final selectedPlayer = maProvider.selectedPlayer;
final availablePlayers = maProvider.availablePlayers;
// 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 colorScheme = Theme.of(context).colorScheme;
// Count players that are currently playing MA content (excluding selected player)
@@ -45,7 +46,7 @@ class PlayerSelector extends StatelessWidget {
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
onTap: () => _showPlayerSelector(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
@@ -81,11 +82,7 @@ class PlayerSelector extends StatelessWidget {
);
}
void _showPlayerSelector(
BuildContext context,
MusicAssistantProvider provider,
List players,
) {
void _showPlayerSelector(BuildContext context) {
// Use the new player reveal animation
GlobalPlayerOverlay.showPlayerReveal();
}
+209 -2
View File
@@ -1,5 +1,6 @@
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';
@@ -7,9 +8,12 @@ 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;
@@ -29,9 +33,200 @@ class PlaylistCard extends StatefulWidget {
State<PlaylistCard> createState() => _PlaylistCardState();
}
class _PlaylistCardState extends State<PlaylistCard> {
class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
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>();
@@ -67,6 +262,18 @@ class _PlaylistCardState extends State<PlaylistCard> {
});
});
},
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: [
+2 -1
View File
@@ -1,6 +1,7 @@
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';
@@ -98,7 +99,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
if (_playlists.isEmpty) {
return Center(
child: Text(
'No playlists found',
S.of(context)!.noPlaylistsFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+209 -2
View File
@@ -1,5 +1,6 @@
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';
@@ -8,9 +9,12 @@ 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;
@@ -32,9 +36,200 @@ class PodcastCard extends StatefulWidget {
State<PodcastCard> createState() => _PodcastCardState();
}
class _PodcastCardState extends State<PodcastCard> {
class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
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>();
@@ -73,6 +268,18 @@ class _PodcastCardState extends State<PodcastCard> {
});
});
},
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: [
+2 -1
View File
@@ -1,6 +1,7 @@
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 +107,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
if (_podcasts.isEmpty) {
return Center(
child: Text(
'No podcasts found',
S.of(context)!.noPodcasts,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+208 -1
View File
@@ -7,6 +7,10 @@ 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;
@@ -26,9 +30,200 @@ class RadioStationCard extends StatefulWidget {
State<RadioStationCard> createState() => _RadioStationCardState();
}
class _RadioStationCardState extends State<RadioStationCard> {
class _RadioStationCardState extends State<RadioStationCard> with LibraryStatusMixin {
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>();
@@ -58,6 +253,18 @@ class _RadioStationCardState extends State<RadioStationCard> {
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: [
+2 -1
View File
@@ -1,6 +1,7 @@
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';
@@ -98,7 +99,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
if (_radioStations.isEmpty) {
return Center(
child: Text(
'No radio stations found',
S.of(context)!.noRadioStations,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
+4 -1
View File
@@ -2,6 +2,7 @@ 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';
@@ -172,7 +173,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
if (_series.isEmpty) {
return Center(
child: Text(
'No series found',
S.of(context)!.noSeriesFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
@@ -406,6 +407,8 @@ class _SeriesCard extends StatelessWidget {
? CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
memCacheWidth: 192,
memCacheHeight: 192,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
+222 -9
View File
@@ -1,9 +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 '../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;
@@ -96,7 +101,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
if (_tracks.isEmpty) {
return Center(
child: Text(
'No tracks found',
S.of(context)!.noTracksFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
@@ -176,7 +181,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
}
}
class _TrackCard extends StatelessWidget {
class _TrackCard extends StatefulWidget {
final Track track;
final List<Track> tracks;
final int index;
@@ -187,13 +192,209 @@ class _TrackCard extends StatelessWidget {
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(track, size: 256);
final albumImageUrl = track.album != null
? maProvider.getImageUrl(track.album!, size: 256)
final trackImageUrl = maProvider.getImageUrl(widget.track, size: 256);
final albumImageUrl = widget.track.album != null
? maProvider.getImageUrl(widget.track.album!, size: 256)
: null;
final imageUrl = trackImageUrl ?? albumImageUrl;
final colorScheme = Theme.of(context).colorScheme;
@@ -202,6 +403,18 @@ class _TrackCard extends StatelessWidget {
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: [
@@ -241,7 +454,7 @@ class _TrackCard extends StatelessWidget {
const SizedBox(height: 8),
// Track title
Text(
track.name,
widget.track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
@@ -251,7 +464,7 @@ class _TrackCard extends StatelessWidget {
),
// Artist name
Text(
track.artistsString,
widget.track.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
@@ -274,8 +487,8 @@ class _TrackCard extends StatelessWidget {
await maProvider.playTracks(
maProvider.selectedPlayer!.playerId,
tracks,
startIndex: index,
widget.tracks,
startIndex: widget.index,
);
}
}
+58 -26
View File
@@ -171,8 +171,8 @@ class _VolumeControlState extends State<VolumeControl> {
@override
Widget build(BuildContext context) {
final maProvider = context.watch<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
// Use select to only rebuild when selectedPlayer changes (not on every provider update)
final player = context.select<MusicAssistantProvider, dynamic>((p) => p.selectedPlayer);
if (player == null) {
return const SizedBox.shrink();
@@ -203,6 +203,7 @@ 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);
},
);
@@ -216,7 +217,7 @@ class _VolumeControlState extends State<VolumeControl> {
Icons.volume_down,
color: Colors.white70,
),
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, -1),
onPressed: () => _adjustVolume(context.read<MusicAssistantProvider>(), player.playerId, currentVolume, -1),
),
// Slider with floating teardrop indicator
Expanded(
@@ -225,11 +226,8 @@ class _VolumeControlState extends State<VolumeControl> {
child: LayoutBuilder(
builder: (context, constraints) {
final sliderWidth = constraints.maxWidth;
// Calculate thumb position to match Flutter Slider's internal positioning
// The Slider uses overlayRadius as padding on each side for the track
const overlayRadius = 20.0; // Must match SliderThemeData overlayShape
final thumbPosition = overlayRadius +
(currentVolume.clamp(0.0, 1.0) * (sliderWidth - 2 * overlayRadius));
// Calculate thumb position for the custom slider track
final thumbPosition = sliderWidth * currentVolume.clamp(0.0, 1.0);
return Stack(
clipBehavior: Clip.none,
@@ -287,7 +285,7 @@ class _VolumeControlState extends State<VolumeControl> {
setState(() {
_pendingVolume = newVolume;
});
_sendVolumeUpdate(maProvider, player.playerId, newVolume);
_sendVolumeUpdate(context.read<MusicAssistantProvider>(), player.playerId, newVolume);
}
},
onHorizontalDragEnd: (details) {
@@ -301,7 +299,7 @@ class _VolumeControlState extends State<VolumeControl> {
FlutterVolumeController.setVolume(finalVolume);
_systemVolume = finalVolume;
} else {
maProvider.setVolume(player.playerId, volumeLevel);
context.read<MusicAssistantProvider>().setVolume(player.playerId, volumeLevel);
}
_exitPrecisionMode();
@@ -311,22 +309,56 @@ class _VolumeControlState extends State<VolumeControl> {
_pendingVolume = null;
});
},
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
activeTrackColor: _inPrecisionMode ? accentColor : Colors.white,
inactiveTrackColor: Colors.white.withOpacity(0.3),
thumbColor: _inPrecisionMode ? accentColor : Colors.white,
overlayColor: (_inPrecisionMode ? accentColor : Colors.white).withOpacity(0.2),
// Custom slider track (no Overlay needed - Flutter 3.38 Slider uses OverlayPortal)
child: SizedBox(
height: 48,
child: Center(
child: SizedBox(
height: 20,
child: Stack(
clipBehavior: Clip.none,
children: [
// Inactive track (full width, thinner)
Positioned(
left: 0,
right: 0,
top: 8.5,
child: Container(
height: 3,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(1.5),
),
),
),
// Active track
Positioned(
left: 0,
top: 7,
child: Container(
width: sliderWidth * currentVolume.clamp(0.0, 1.0),
height: 6,
decoration: BoxDecoration(
color: _inPrecisionMode ? accentColor : Colors.white,
borderRadius: BorderRadius.circular(3),
),
),
),
// Thumb
Positioned(
left: (sliderWidth * currentVolume.clamp(0.0, 1.0)) - 6,
top: 4,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: _inPrecisionMode ? accentColor : Colors.white,
shape: BoxShape.circle,
),
),
),
],
),
child: AbsorbPointer(
// AbsorbPointer prevents Slider from handling gestures
// Our GestureDetector handles everything for live updates
child: Slider(
value: currentVolume.clamp(0.0, 1.0),
onChanged: (_) {},
),
),
),
@@ -356,7 +388,7 @@ class _VolumeControlState extends State<VolumeControl> {
Icons.volume_up,
color: Colors.white70,
),
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, 1),
onPressed: () => _adjustVolume(context.read<MusicAssistantProvider>(), player.playerId, currentVolume, 1),
),
],
);
+71 -68
View File
@@ -5,23 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev"
source: hosted
version: "72.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.2"
version: "91.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev"
source: hosted
version: "6.7.0"
version: "8.4.1"
archive:
dependency: transitive
description:
@@ -90,10 +85,10 @@ packages:
dependency: transitive
description:
name: build
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
sha256: "7174c5d84b0fed00a1f5e7543597b35d67560465ae3d909f0889b8b20419d5e3"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "3.0.1"
build_config:
dependency: transitive
description:
@@ -114,26 +109,26 @@ packages:
dependency: transitive
description:
name: build_resolvers
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
sha256: "82730bf3d9043366ba8c02e4add05842a10739899520a6a22ddbd22d333bd5bb"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "3.0.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
sha256: "32c6b3d172f1f46b7c4df6bc4a47b8d88afb9e505dd4ace4af80b3c37e89832b"
url: "https://pub.dev"
source: hosted
version: "2.4.13"
version: "2.6.1"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
sha256: "4b188774b369104ad96c0e4ca2471e5162f0566ce277771b179bed5eabf2d048"
url: "https://pub.dev"
source: hosted
version: "7.3.2"
version: "9.2.1"
built_collection:
dependency: transitive
description:
@@ -174,14 +169,22 @@ 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:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
charcode:
dependency: transitive
description:
@@ -210,10 +213,10 @@ packages:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
code_builder:
dependency: transitive
description:
@@ -226,10 +229,10 @@ packages:
dependency: transitive
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.1"
convert:
dependency: transitive
description:
@@ -258,10 +261,10 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
url: "https://pub.dev"
source: hosted
version: "2.3.7"
version: "3.1.3"
device_info_plus:
dependency: "direct main"
description:
@@ -282,18 +285,18 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: af3941e4d544727b2eb80590eb64e9cb8d77cd68c7690265502ea6a2427aa621
sha256: "5ea2f718558c0b31d4b8c36a3d8e5b7016f1265f46ceb5a5920e16117f0c0d6a"
url: "https://pub.dev"
source: hosted
version: "2.23.1"
version: "2.30.1"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: fa98fdbb7303a1b5b2dc110cb516eda2253a5d291680f8cbc72b1af24099f7f9
sha256: "892dfb5d69d9e604bdcd102a9376de8b41768cf7be93fd26b63cfc4d8f91ad5f"
url: "https://pub.dev"
source: hosted
version: "2.23.1"
version: "2.30.1"
drift_flutter:
dependency: "direct main"
description:
@@ -314,10 +317,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.3"
ffi:
dependency: transitive
description:
@@ -515,7 +518,7 @@ packages:
source: hosted
version: "4.0.2"
image:
dependency: transitive
dependency: "direct main"
description:
name: image
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
@@ -526,10 +529,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.19.0"
version: "0.20.2"
io:
dependency: transitive
description:
@@ -582,26 +585,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.5"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -618,22 +621,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
macros:
dependency: transitive
description:
name: macros
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
url: "https://pub.dev"
source: hosted
version: "0.1.2-main.4"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
@@ -650,14 +645,22 @@ 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:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -718,10 +721,10 @@ packages:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_parsing:
dependency: transitive
description:
@@ -950,15 +953,15 @@ packages:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "4.2.0"
source_span:
dependency: transitive
description:
@@ -1027,26 +1030,26 @@ packages:
dependency: transitive
description:
name: sqlparser
sha256: "4cad4b2c5f63dc9ea1a8dcffb58cf762322bea5dd8836870164a65e913bdae41"
sha256: f52f5d5649dcc13ed198c4176ddef74bf6851c30f4f31603f1b37788695b93e2
url: "https://pub.dev"
source: hosted
version: "0.40.0"
version: "0.43.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
stream_transform:
dependency: transitive
description:
@@ -1083,10 +1086,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.2"
version: "0.7.7"
timing:
dependency: transitive
description:
@@ -1171,10 +1174,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@@ -1248,5 +1251,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.5.0 <4.0.0"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.24.0"
+4 -2
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.9.1-beta+49
version: 3.0.2+54
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -11,10 +11,11 @@ dependencies:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.19.0
intl: ^0.20.0
# 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
@@ -37,6 +38,7 @@ 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