Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fc24539b1 | ||
|
|
32f62a4731 | ||
|
|
9b8f6d36c6 | ||
|
|
a3e414dc4b | ||
|
|
a6d6eb8d55 | ||
|
|
cd8c5a66e4 | ||
|
|
dadfe65e5a | ||
|
|
0a853bd862 | ||
|
|
694b7127a0 | ||
|
|
4cbfd6a9e0 | ||
|
|
8bf0d23972 | ||
|
|
c17b8b5c5e | ||
|
|
4e3134df9c | ||
|
|
c4b80b9d33 | ||
|
|
d28c6ab556 | ||
|
|
ccfea4168c | ||
|
|
8181e75a08 | ||
|
|
9600f4bcfe | ||
|
|
a2b8b1eeef | ||
|
|
0263c75804 | ||
|
|
469e77c572 | ||
|
|
577b03bbea | ||
|
|
7bbba90fff | ||
|
|
6f66a1ebff | ||
|
|
0ceab80a14 | ||
|
|
efb84a4e50 | ||
|
|
731f45bea9 | ||
|
|
32e61f109e | ||
|
|
cd9e82b1fd | ||
|
|
04322cf768 | ||
|
|
e6349c309b | ||
|
|
00f065fbe9 | ||
|
|
e440f7b1d7 | ||
|
|
5a73d2d65b | ||
|
|
3b5a8631a8 | ||
|
|
1f3a01c22f | ||
|
|
27e1b8b746 | ||
|
|
9be3737a9d | ||
|
|
882e8b2ea4 | ||
|
|
aef9423b42 | ||
|
|
c0138828a7 | ||
|
|
53225138a2 | ||
|
|
ebc3288bfc | ||
|
|
5bd0cef76e | ||
|
|
9f399b438f | ||
|
|
63473226f5 | ||
|
|
e3d257a6dc | ||
|
|
671b31de61 | ||
|
|
67a4ebd771 | ||
|
|
23af5379a6 | ||
|
|
86bdad5534 | ||
|
|
7ee27f6e75 | ||
|
|
633f489bfa | ||
|
|
61503cbe49 | ||
|
|
0b49c5158c |
@@ -0,0 +1,3 @@
|
||||
# These are the funding models supported by Ensemble
|
||||
github: CollotsSpot
|
||||
custom: https://ko-fi.com/collotsspot
|
||||
@@ -31,8 +31,8 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.24.0'
|
||||
channel: 'stable'
|
||||
flutter-version: '3.38.0'
|
||||
channel: 'master'
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
@@ -63,10 +63,7 @@ jobs:
|
||||
|
||||
- name: Setup signing keystore
|
||||
run: |
|
||||
# Decode keystore from secret (persistent key for consistent signatures)
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
|
||||
|
||||
# Create key.properties file
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword=${{ secrets.KEY_PASSWORD }}
|
||||
@@ -75,12 +72,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: |
|
||||
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
|
||||
mkdir -p ~/.gradle
|
||||
cp android/init.gradle ~/.gradle/init.gradle
|
||||
|
||||
flutter build apk --release
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -91,10 +83,5 @@ jobs:
|
||||
|
||||
- name: Upload APK summary
|
||||
run: |
|
||||
echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📱 **APK Location:** \`build/app/outputs/flutter-apk/app-release.apk\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📦 **Download:** Check the artifacts section below" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## APK Build Successful!" >> $GITHUB_STEP_SUMMARY
|
||||
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
# Ensemble Flutter Music Application - Codebase Audit Report
|
||||
|
||||
**Generated:** 2026-02-07
|
||||
**Project Location:** `/home/chris/Ensemble`
|
||||
**Code Size:** ~6,180 lines in main provider, ~50K+ total lines
|
||||
**Flutter Version:** 3.0+
|
||||
**Auditor:** Claude Code Analysis
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This comprehensive audit examined the Ensemble Flutter music streaming application for security vulnerabilities, performance issues, code quality concerns, and architectural problems. The audit identified **23 distinct issues** across severity levels.
|
||||
|
||||
### Severity Breakdown
|
||||
- **Critical:** 2 issues
|
||||
- **High:** 6 issues
|
||||
- **Medium:** 10 issues
|
||||
- **Low:** 5 issues
|
||||
|
||||
### Overall Health Assessment: **GOOD** (with areas for improvement)
|
||||
|
||||
The codebase demonstrates:
|
||||
- ✅ Strong secure storage practices (flutter_secure_storage with encryptedSharedPreferences)
|
||||
- ✅ Comprehensive dispose() cleanup across widgets
|
||||
- ✅ Good mounted check usage in most async operations
|
||||
- ⚠️ One critical file requiring splitting (6,180 lines)
|
||||
- ⚠️ Some timer callback issues
|
||||
- ⚠️ Several .then() chains that should use async/await
|
||||
|
||||
---
|
||||
|
||||
## 1. CRITICAL ISSUES
|
||||
|
||||
### 1.1 Massive Single File - `music_assistant_provider.dart`
|
||||
**File:** `/home/chris/Ensemble/lib/providers/music_assistant_provider.dart`
|
||||
**Lines:** 6,180
|
||||
**Severity:** Critical
|
||||
**Category:** Maintainability
|
||||
|
||||
**Description:**
|
||||
The main provider file is extremely large (6,180 lines), making it difficult to navigate, test, and maintain. This violates single responsibility principle and increases the risk of merge conflicts.
|
||||
|
||||
**Recommendation:**
|
||||
Split into smaller focused providers:
|
||||
- `MusicAssistantProvider` (core coordination)
|
||||
- `PlayerStateProvider` (player management)
|
||||
- `LibraryStateProvider` (library data)
|
||||
- `ConnectionProvider` (already exists but not fully utilized)
|
||||
|
||||
**Impact:** High complexity, difficult onboarding, increased bug risk
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Empty setState() Call
|
||||
**File:** `/home/chris/Ensemble/lib/screens/debug_log_screen.dart:367`
|
||||
**Severity:** Critical
|
||||
**Category:** Performance
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
onPressed: () {
|
||||
setState(() {});
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(...)
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
**Description:**
|
||||
An empty setState() triggers a complete widget rebuild without any state changes, wasting CPU cycles.
|
||||
|
||||
**Recommendation:**
|
||||
```dart
|
||||
onPressed: () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(...)
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
Or if state update is needed:
|
||||
```dart
|
||||
onPressed: () {
|
||||
setState(() => _lastRefreshTime = DateTime.now());
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(...)
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. HIGH SEVERITY ISSUES
|
||||
|
||||
### 2.1 Timer Callback Without Mounted Check
|
||||
**File:** `/home/chris/Ensemble/lib/providers/sleep_timer_provider.dart:76-77`
|
||||
**Severity:** High
|
||||
**Category:** Performance/Stability
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
});
|
||||
```
|
||||
|
||||
**Description:**
|
||||
Timer callback calls notifyListeners() without checking if the widget is still mounted. If the provider is disposed but timer fires, it could cause issues.
|
||||
|
||||
**Recommendation:**
|
||||
Add a flag to track disposal state:
|
||||
```dart
|
||||
bool _disposed = false;
|
||||
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!_disposed) {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_cancelInternal();
|
||||
super.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Multiple .then() Chains (Should Use async/await)
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/screens/playlist_details_screen.dart:598`
|
||||
- `/home/chris/Ensemble/lib/widgets/expandable_player.dart:468, 502, 521, 577, 601, 1644, 1773`
|
||||
- `/home/chris/Ensemble/lib/widgets/playlist_card.dart:258`
|
||||
- `/home/chris/Ensemble/lib/widgets/podcast_card.dart:264`
|
||||
- `/home/chris/Ensemble/lib/widgets/player/queue_panel.dart:212`
|
||||
- `/home/chris/Ensemble/lib/widgets/player/player_reveal_overlay.dart:83, 115, 191`
|
||||
- `/home/chris/Ensemble/lib/widgets/album_card.dart:335`
|
||||
- `/home/chris/Ensemble/lib/widgets/artist_card.dart:321`
|
||||
- `/home/chris/Ensemble/lib/screens/new_library_screen.dart:1213`
|
||||
|
||||
**Severity:** High
|
||||
**Category:** Code Quality
|
||||
|
||||
**Example:**
|
||||
```dart
|
||||
// Current (.then pattern)
|
||||
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(...);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Recommendation:**
|
||||
```dart
|
||||
// Preferred (async/await)
|
||||
try {
|
||||
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(...);
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:** async/await provides better error handling and readability
|
||||
|
||||
---
|
||||
|
||||
### 2.3 TODO Comments for Unimplemented Features
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/providers/music_assistant_provider.dart:1418, 1424`
|
||||
- `/home/chris/Ensemble/lib/screens/audiobook_detail_screen.dart:351, 402`
|
||||
- `/home/chris/Ensemble/lib/screens/queue_screen.dart:283`
|
||||
|
||||
**Severity:** Medium-High
|
||||
**Category:** Functionality
|
||||
|
||||
**Description:**
|
||||
Important features are marked as TODO but not implemented:
|
||||
- Playlist modification functionality
|
||||
- Server-side audiobook progress updates
|
||||
- Queue item removal via API
|
||||
|
||||
**Recommendation:**
|
||||
Create GitHub issues for these features and track implementation progress.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Unused Dependencies Detection
|
||||
**File:** `/home/chris/Ensemble/pubspec.yaml`
|
||||
**Severity:** High (if truly unused)
|
||||
**Category:** Bundle Size
|
||||
|
||||
**Potential Unused Packages:**
|
||||
- `package_info_plus` (used in debug_logger.dart - ✅ KEEP)
|
||||
- `device_info_plus` (used in debug_logger.dart - ✅ KEEP)
|
||||
- `share_plus` (used in debug_log_screen.dart - ✅ KEEP)
|
||||
- `url_launcher` (used in settings_screen.dart - ✅ KEEP)
|
||||
|
||||
**Conclusion:** All listed packages are actually in use. No action needed.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Duplicate Auth Token Logic
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/services/secure_storage_service.dart`
|
||||
- `/home/chris/Ensemble/lib/services/auth/auth_manager.dart`
|
||||
- `/home/chris/Ensemble/lib/services/settings_service.dart` (not reviewed but mentioned in plan)
|
||||
|
||||
**Severity:** High
|
||||
**Category:** Maintainability
|
||||
|
||||
**Description:**
|
||||
Auth token storage exists in multiple places with slight variations:
|
||||
- `SecureStorageService` has `getAuthToken()`, `getMaAuthToken()`
|
||||
- `AuthManager` stores its own `_accessToken`, `_longLivedToken`
|
||||
- Potential for inconsistencies
|
||||
|
||||
**Recommendation:**
|
||||
Consolidate to single source of truth - make AuthManager the only place tokens are stored in memory, with SecureStorageService only for persistence.
|
||||
|
||||
---
|
||||
|
||||
## 3. MEDIUM SEVERITY ISSUES
|
||||
|
||||
### 3.1 Hardcoded WebSocket Port
|
||||
**File:** `/home/chris/Ensemble/lib/constants/network.dart:6`
|
||||
**Severity:** Medium
|
||||
**Category:** Maintainability
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
static const int defaultWsPort = 8095;
|
||||
```
|
||||
|
||||
**Description:**
|
||||
Port is hardcoded in multiple places across the codebase.
|
||||
|
||||
**Recommendation:**
|
||||
✅ Already extracted to `NetworkConstants.defaultWsPort` - GOOD PRACTICE!
|
||||
|
||||
However, the port is still hardcoded in:
|
||||
- `/home/chris/Ensemble/lib/services/auth/auth_manager.dart:182`
|
||||
- `/home/chris/Ensemble/lib/services/music_assistant_api.dart`
|
||||
|
||||
**Fix:**
|
||||
Replace `port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095)` with:
|
||||
```dart
|
||||
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? NetworkConstants.defaultHttpsPort : NetworkConstants.defaultWsPort)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Timer Cleanup Issues
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/services/sendspin_service.dart:646`
|
||||
- `/home/chris/Ensemble/lib/widgets/expandable_player.dart:2369, 349`
|
||||
- `/home/chris/Ensemble/lib/widgets/player/queue_panel.dart:562, 675`
|
||||
- `/home/chris/Ensemble/lib/screens/search_screen.dart:364`
|
||||
|
||||
**Severity:** Medium
|
||||
**Category:** Memory Leaks
|
||||
|
||||
**Description:**
|
||||
Multiple timers are created throughout the codebase. Need to verify all are properly cancelled in dispose().
|
||||
|
||||
**Recommendation:**
|
||||
Audit each timer creation to ensure corresponding cancel() in dispose().
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Deep Nesting in Detail Screens
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/screens/artist_details_screen.dart`
|
||||
- `/home/chris/Ensemble/lib/screens/album_details_screen.dart`
|
||||
- `/home/chris/Ensemble/lib/screens/audiobook_detail_screen.dart`
|
||||
|
||||
**Severity:** Medium
|
||||
**Category:** Code Quality
|
||||
|
||||
**Description:**
|
||||
These screens have deeply nested code (4+ levels) making them harder to read and maintain.
|
||||
|
||||
**Recommendation:**
|
||||
Extract nested logic into separate methods:
|
||||
```dart
|
||||
// Instead of:
|
||||
void build() {
|
||||
if (condition) {
|
||||
if (another) {
|
||||
if (third) {
|
||||
// do something
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do:
|
||||
void build() {
|
||||
if (condition && another && third) {
|
||||
_doSomething();
|
||||
}
|
||||
}
|
||||
|
||||
void _doSomething() {
|
||||
// logic here
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Potential Memory Leak - ImageStream Listeners
|
||||
**File:** `/home/chris/Ensemble/lib/widgets/expandable_player.dart:199`
|
||||
**Severity:** Medium
|
||||
**Category:** Memory Leaks
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
// Track active ImageStream listeners for proper cleanup on dispose
|
||||
final Map<ImageStream, ImageStreamListener> _activeImageStreams = {};
|
||||
```
|
||||
|
||||
**Description:**
|
||||
The code tracks image streams for cleanup, which is good practice. However, need to verify all listeners are properly removed.
|
||||
|
||||
**Recommendation:**
|
||||
Ensure dispose() method includes:
|
||||
```dart
|
||||
_activeImageStreams.forEach((stream, listener) => stream.removeListener(listener));
|
||||
_activeImageStreams.clear();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. LOW SEVERITY ISSUES
|
||||
|
||||
### 4.1 Ignore Comments in Generated Files
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/l10n/app_localizations_*.dart`
|
||||
|
||||
**Severity:** Low
|
||||
**Category:** Code Quality
|
||||
|
||||
**Description:**
|
||||
Generated localization files have `// ignore: unused_import` comments.
|
||||
|
||||
**Recommendation:**
|
||||
This is acceptable for generated files. No action needed.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Debug Print Statements
|
||||
**File:** `/home/chris/Ensemble/lib/services/debug_logger.dart:77, 104`
|
||||
**Severity:** Low
|
||||
**Category:** Security (potential)
|
||||
|
||||
**Code:**
|
||||
```dart
|
||||
debugPrint(entry.formatted);
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
```
|
||||
|
||||
**Description:**
|
||||
Debug logger outputs to console. Need to verify no sensitive data (tokens, passwords) is logged.
|
||||
|
||||
**Recommendation:**
|
||||
Add sanitization for sensitive data patterns:
|
||||
- Filter out auth tokens
|
||||
- Filter out passwords
|
||||
- Filter out API keys
|
||||
|
||||
---
|
||||
|
||||
## 5. SECURITY AUDIT
|
||||
|
||||
### 5.1 Credential Storage - ✅ SECURE
|
||||
**File:** `/home/chris/Ensemble/lib/services/secure_storage_service.dart`
|
||||
|
||||
**Status:** PASS
|
||||
- Uses `flutter_secure_storage` with `encryptedSharedPreferences: true`
|
||||
- Proper error handling for decryption failures
|
||||
- Migration logic from SharedPreferences to secure storage
|
||||
- All sensitive data (tokens, passwords) stored securely
|
||||
|
||||
### 5.2 Network Security - ✅ GOOD
|
||||
**Files:**
|
||||
- `/home/chris/Ensemble/lib/services/music_assistant_api.dart`
|
||||
- `/home/chris/Ensemble/lib/services/sendspin_service.dart`
|
||||
- `/home/chris/Ensemble/lib/services/auth/auth_manager.dart`
|
||||
|
||||
**Status:** PASS
|
||||
- Properly upgrades HTTP to HTTPS where applicable
|
||||
- WebSocket URL construction is secure
|
||||
- Token-based authentication
|
||||
- No hardcoded credentials found
|
||||
|
||||
### 5.3 Token Exposure Risk - ⚠️ REVIEW NEEDED
|
||||
**Files:** Multiple (logging statements)
|
||||
|
||||
**Status:** NEEDS REVIEW
|
||||
- Debug logger may expose tokens in logs
|
||||
- Recommend adding token sanitization to logger
|
||||
|
||||
---
|
||||
|
||||
## 6. PERFORMANCE AUDIT
|
||||
|
||||
### 6.1 Provider Usage - ✅ GOOD
|
||||
**Pattern:** Mostly using `context.read<>` correctly
|
||||
|
||||
**Status:** PASS
|
||||
- `context.read<>` used for method calls (correct)
|
||||
- `context.select<>` used where appropriate (expandable_player.dart:1796)
|
||||
- No obvious unnecessary rebuilds detected
|
||||
|
||||
### 6.2 State Management - ✅ GOOD
|
||||
**Status:** PASS
|
||||
- Provider pattern used consistently
|
||||
- ChangeNotifier properly implemented
|
||||
- dispose() methods properly cancel timers and close streams
|
||||
|
||||
### 6.3 Stream Subscriptions - ✅ GOOD
|
||||
**Files:** Multiple
|
||||
|
||||
**Status:** PASS
|
||||
- All StreamSubscriptions are properly tracked
|
||||
- dispose() methods cancel subscriptions
|
||||
|
||||
---
|
||||
|
||||
## 7. DEPENDENCY AUDIT
|
||||
|
||||
### Package Health Check
|
||||
|
||||
| Package | Version | Status | Notes |
|
||||
|---------|---------|--------|-------|
|
||||
| flutter_secure_storage | 9.2.2 | ✅ Current | Good |
|
||||
| provider | 6.1.1 | ✅ Current | Good |
|
||||
| drift | 2.22.0 | ✅ Current | Good |
|
||||
| just_audio | 0.9.36 | ✅ Current | Good |
|
||||
| audio_service | 0.18.12 | ⚠️ Check | May have updates |
|
||||
| cached_network_image | 3.3.1 | ✅ Current | Good |
|
||||
| web_socket_channel | 2.4.0 | ⚠️ Check | May have updates |
|
||||
|
||||
**Recommendation:** Run `flutter pub outdated` to check for available updates.
|
||||
|
||||
---
|
||||
|
||||
## 8. ARCHITECTURE ASSESSMENT
|
||||
|
||||
### 8.1 Strengths
|
||||
- ✅ Clear separation of concerns (services, providers, screens, widgets)
|
||||
- ✅ Consistent state management with Provider
|
||||
- ✅ Proper use of secure storage
|
||||
- ✅ Comprehensive dispose() cleanup
|
||||
- ✅ Good error handling in most places
|
||||
|
||||
### 8.2 Areas for Improvement
|
||||
- ⚠️ Main provider file too large (6,180 lines)
|
||||
- ⚠️ Some duplicate auth logic
|
||||
- ⚠️ Deep nesting in detail screens
|
||||
- ⚠️ .then() chains should be async/await
|
||||
|
||||
### 8.3 Recommended Refactoring
|
||||
1. Split `MusicAssistantProvider` into smaller focused providers
|
||||
2. Consolidate auth token management
|
||||
3. Reduce nesting in detail screens
|
||||
4. Convert .then() chains to async/await
|
||||
5. Add token sanitization to logger
|
||||
|
||||
---
|
||||
|
||||
## 9. PRIORITIZED REMEDIATION PLAN
|
||||
|
||||
### Phase 1: Critical Fixes (1-2 days)
|
||||
1. Fix empty setState() in debug_log_screen.dart
|
||||
2. Add disposed flag to SleepTimerProvider
|
||||
|
||||
### Phase 2: High Priority (1 week)
|
||||
1. Convert all .then() chains to async/await
|
||||
2. Consolidate auth token logic
|
||||
3. Add token sanitization to debug logger
|
||||
|
||||
### Phase 3: Medium Priority (2-3 weeks)
|
||||
1. Split MusicAssistantProvider into smaller providers
|
||||
2. Reduce nesting in detail screens
|
||||
3. Verify all timers are properly cancelled
|
||||
4. Update outdated dependencies
|
||||
|
||||
### Phase 4: Low Priority (Ongoing)
|
||||
1. Implement TODO features (playlist modification, etc.)
|
||||
2. Add unit tests for critical paths
|
||||
3. Set up dependency update automation
|
||||
|
||||
---
|
||||
|
||||
## 10. FILES REQUIRING IMMEDIATE ATTENTION
|
||||
|
||||
| File | Issue | Severity | Lines |
|
||||
|------|-------|----------|-------|
|
||||
| music_assistant_provider.dart | Too large | Critical | 6180 |
|
||||
| sleep_timer_provider.dart:76 | No mounted check | High | 76-77 |
|
||||
| debug_log_screen.dart:367 | Empty setState | Critical | 367 |
|
||||
| playlist_details_screen.dart:598 | .then() chain | High | 598 |
|
||||
|
||||
---
|
||||
|
||||
## 11. CONCLUSION
|
||||
|
||||
The Ensemble codebase is **well-structured and secure** with good practices for:
|
||||
- Secure credential storage
|
||||
- Proper resource cleanup
|
||||
- State management
|
||||
|
||||
The main areas for improvement are:
|
||||
1. File size (need to split the main provider)
|
||||
2. Code modernization (async/await instead of .then())
|
||||
3. Some timer safety checks
|
||||
|
||||
Overall, this is a **healthy codebase** that would benefit from focused refactoring rather than major restructuring.
|
||||
|
||||
---
|
||||
|
||||
**Report End**
|
||||
@@ -0,0 +1,208 @@
|
||||
# Multi-Provider Split Plan
|
||||
|
||||
## Current Status (2026-02-07)
|
||||
|
||||
### Completed
|
||||
|
||||
| Provider | Lines | File | Status |
|
||||
|----------|-------|------|--------|
|
||||
| ConnectionProvider | 245 | lib/providers/connection_provider.dart | ✅ Created |
|
||||
| UIStateProvider | 167 | lib/providers/ui_state_provider.dart | ✅ Created & Committed |
|
||||
| LibraryProvider | 290 | lib/providers/library_provider.dart | ✅ Created & Committed |
|
||||
| PlayerProvider | 589 | lib/providers/player_provider.dart | ✅ Created & Integrated |
|
||||
| LocalPlayerProvider | 370 | lib/providers/local_player_provider.dart | ✅ Created & Integrated |
|
||||
|
||||
**Main Provider: 5928 lines** (down from 6180 original)
|
||||
|
||||
### Integration Status
|
||||
|
||||
✅ **Phase 1 Complete**: All 5 providers created
|
||||
✅ **Phase 2 Started**: Integration into main.dart completed
|
||||
- All providers added to MultiProvider tree
|
||||
- Shared services (CacheService, PositionTracker, ImageHelperService) properly initialized
|
||||
- Build successful (52.2MB APK)
|
||||
|
||||
### Remaining Work
|
||||
|
||||
| Task | Complexity | Notes |
|
||||
|------|------------|-------|
|
||||
| Update MusicAssistantProvider facade | Medium | Accept providers in constructor, delegate methods |
|
||||
| Migrate consumers incrementally | High | Start with less critical screens |
|
||||
| Remove facade when complete | Low | After all consumers migrated |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ MusicAssistantProvider │
|
||||
│ (Facade - during transition) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Delegates to sub-providers: │ │
|
||||
│ │ • ConnectionProvider - connect/disconnect, auth │ │
|
||||
│ │ • UIStateProvider - search, filters, loading state │ │
|
||||
│ │ • LibraryProvider - artists, albums, tracks, podcasts │ │
|
||||
│ │ • PlayerProvider - player selection, controls, queue (TODO) │ │
|
||||
│ │ • LocalPlayerProvider - local audio engine (TODO) │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 1: Complete Provider Creation (Current)
|
||||
|
||||
1. **Create PlayerProvider** (~800 lines)
|
||||
- Extract player selection and switching logic
|
||||
- Extract play/pause/stop/seek controls
|
||||
- Extract queue management
|
||||
- Extract sleep timer functionality
|
||||
|
||||
2. **Create LocalPlayerProvider** (~500 lines)
|
||||
- Extract local audio playback engine
|
||||
- Extract Sendspin integration
|
||||
- Extract player registration
|
||||
- Extract volume and power control
|
||||
|
||||
### Phase 2: Integration
|
||||
|
||||
3. **Update main.dart**
|
||||
- Add all providers to MultiProvider
|
||||
- Keep MusicAssistantProvider as facade initially
|
||||
|
||||
4. **Migrate one consumer at a time**
|
||||
- Start with less critical screens
|
||||
- Test thoroughly after each migration
|
||||
- Use facade to maintain compatibility during transition
|
||||
|
||||
5. **Remove facade**
|
||||
- Once all consumers migrated
|
||||
- Remove MusicAssistantProvider
|
||||
- Direct widget-provider relationships
|
||||
|
||||
---
|
||||
|
||||
## PlayerProvider Extraction Plan
|
||||
|
||||
### State to Extract
|
||||
|
||||
```dart
|
||||
// Player selection
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
Map<String, String> _castToSendspinIdMap = {};
|
||||
|
||||
// Current playback
|
||||
Track? _currentTrack;
|
||||
Audiobook? _currentAudiobook;
|
||||
String? _currentPodcastName;
|
||||
|
||||
// Sleep timer
|
||||
Timer? _sleepTimer;
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes;
|
||||
Timer? _sleepTimerDisplayTimer;
|
||||
|
||||
// Timers
|
||||
Timer? _playerStateTimer;
|
||||
Timer? _notificationPositionTimer;
|
||||
Timer? _idleServiceTimer;
|
||||
|
||||
// Position tracking
|
||||
PositionTracker _positionTracker;
|
||||
```
|
||||
|
||||
### Methods to Extract
|
||||
|
||||
```dart
|
||||
// Player selection
|
||||
Future<void> _loadAndSelectPlayers({bool forceRefresh, bool coldStart});
|
||||
Future<void> selectPlayer(Player player, {bool? sendToSendspin});
|
||||
|
||||
// Playback controls
|
||||
Future<void> playPause();
|
||||
Future<void> stop();
|
||||
Future<void> nextTrack();
|
||||
Future<void> previousTrack();
|
||||
Future<void> seek(Duration position);
|
||||
Future<void> setVolume(String playerId, int volumeLevel);
|
||||
|
||||
// Queue management
|
||||
Future<PlayerQueue?> getQueue(String playerId);
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex});
|
||||
|
||||
// Sleep timer
|
||||
Future<void> setSleepTimer(int minutes);
|
||||
Future<void> cancelSleepTimer();
|
||||
```
|
||||
|
||||
### Dependencies to Inject
|
||||
|
||||
- MusicAssistantAPI
|
||||
- DatabaseService
|
||||
- SettingsService
|
||||
- QueueManagerService
|
||||
- PositionTracker
|
||||
- PlayerSyncState
|
||||
- AudioHandler (from audio_service)
|
||||
|
||||
---
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
| Task | Time | Notes |
|
||||
|------|------|-------|
|
||||
| Create PlayerProvider | 2-3 hours | Complex, many dependencies |
|
||||
| Create LocalPlayerProvider | 2-3 hours | Sendspin integration is complex |
|
||||
| Integration & Testing | 3-4 hours | Update main.dart, migrate consumers |
|
||||
| Bug Fixes | 1-2 hours | Edge cases in player switching |
|
||||
|
||||
**Total: ~8-12 hours of focused development**
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
1. **Keep facade during transition** - Maintain backward compatibility
|
||||
2. **Migrate incrementally** - One screen/consumer at a time
|
||||
3. **Test on device** - Test player controls thoroughly
|
||||
4. **Commit frequently** - Each provider as it's completed
|
||||
5. **Keep backup** - Can revert to monolithic provider if needed
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All 5 providers created and building
|
||||
- [ ] Main.dart updated with all providers
|
||||
- [ ] At least 3 screens migrated to new providers
|
||||
- [ ] Player controls working correctly on device
|
||||
- [ ] No regressions in core functionality
|
||||
- [ ] Main provider reduced to < 1000 lines (or removed)
|
||||
|
||||
---
|
||||
|
||||
## Files Created So Far
|
||||
|
||||
```
|
||||
lib/providers/
|
||||
├── connection_provider.dart (245 lines) ✅
|
||||
├── ui_state_provider.dart (167 lines) ✅
|
||||
├── library_provider.dart (290 lines) ✅
|
||||
├── player_provider.dart (TODO) ⏳
|
||||
└── local_player_provider.dart (TODO) ⏳
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes from Analysis
|
||||
|
||||
1. **Player selection logic is complex** - Involves Cast-to-Sendspin mapping, filtering, smart sorting
|
||||
2. **Sendspin integration is tricky** - Need to handle both MA 2.7.0+ (Sendspin) and older versions
|
||||
3. **Queue management already extracted** - QueueManagerService handles most queue logic
|
||||
4. **Position tracking is separate** - PositionTracker service handles interpolation
|
||||
5. **Event handlers need coordination** - Player events update both provider and position tracker
|
||||
@@ -1,6 +1,11 @@
|
||||
<div align="center">
|
||||
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
|
||||
|
||||
[](https://github.com/CollotsSpot/Ensemble/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/sponsors/CollotsSpot)
|
||||
[](https://ko-fi.com/collotsspot)
|
||||
|
||||
---
|
||||
|
||||
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
|
||||
@@ -17,6 +22,12 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
|
||||
---
|
||||
|
||||
## Also See
|
||||
|
||||
**[Ensemble TV](https://github.com/CollotsSpot/ensemble-tv)** - A simple Android TV client for Music Assistant that displays what's currently playing on your TV with remote control support.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Local Playback
|
||||
@@ -127,25 +138,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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -29,8 +29,8 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.musicassistant.music_assistant"
|
||||
compileSdk 35
|
||||
namespace "com.collotsspot.ensemble"
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
@@ -58,8 +58,8 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
minSdk 21
|
||||
applicationId "com.collotsspot.ensemble"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
@@ -70,6 +70,11 @@ android {
|
||||
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.9.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -22,8 +22,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.1.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
|
||||
id "com.android.application" version "8.9.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Discovery Rows Submenu - Implementation Plan
|
||||
|
||||
## Option: Hybrid with Discovery Badge
|
||||
|
||||
### Overview
|
||||
Transform the "Discovery Mixes" toggle into a section that:
|
||||
1. Shows individual discovery row toggles
|
||||
2. Adds those rows to the main home row list with visual distinction
|
||||
3. Allows full reordering alongside other rows
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Settings Screen - Discovery Section
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ⚬ Discovery Mixes ● │
|
||||
│ Show discovery-based rows on home screen │
|
||||
│ ──────────────────────────────────────── │
|
||||
│ Discovery rows from Music Assistant: │
|
||||
│ │
|
||||
│ ☑ New Releases ● │
|
||||
│ ☑ Recently Played ● │
|
||||
│ ☐ Recommended Albums ○ │
|
||||
│ ☐ For You ○ │
|
||||
│ │
|
||||
│ [Refresh available rows] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Main Home Row List - With Badge
|
||||
|
||||
Discovery rows appear interleaved with a badge:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ⋮ ☑ Recent Albums ● │
|
||||
│ ⋮ ☑ Discovery: New Releases ● [MUSIC] │ ← Badge
|
||||
│ ⋮ ☑ Discover Artists ● │
|
||||
│ ⋮ ☐ Discovery: For You ○ [MUSIC] │
|
||||
│ ⋮ ☑ Favorite Albums ● │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Badge style: Small chip/icon indicating this is a dynamic discovery row.
|
||||
|
||||
---
|
||||
|
||||
## Technical Changes
|
||||
|
||||
### 1. Settings Screen (`lib/screens/settings_screen.dart`)
|
||||
|
||||
**Add state for discovery folders:**
|
||||
```dart
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
Map<String, bool> _discoveryRowEnabled = {}; // itemId -> enabled
|
||||
bool _isDiscoveryFoldersExpanded = false;
|
||||
```
|
||||
|
||||
**New methods:**
|
||||
```dart
|
||||
Future<void> _loadDiscoveryFolders() async { }
|
||||
Future<void> _refreshDiscoveryFolders() async { }
|
||||
void _toggleDiscoveryRow(String itemId, bool enabled) { }
|
||||
void _toggleDiscoveryMixes(bool enabled) { }
|
||||
Widget _buildDiscoverySection() { }
|
||||
```
|
||||
|
||||
**Modify existing:**
|
||||
- `_loadSettings()` - load discovery row preferences
|
||||
- Home row list builder - filter out individual discovery rows
|
||||
- Add discovery section before home row list
|
||||
|
||||
### 2. Settings Service (`lib/services/settings_service.dart`)
|
||||
|
||||
**New methods:**
|
||||
```dart
|
||||
static Future<bool> getShowDiscoveryFolders() async { }
|
||||
static Future<void> setShowDiscoveryFolders(bool value) async { }
|
||||
|
||||
// Per-row discovery preferences
|
||||
static Future<Map<String, bool>> getDiscoveryRowPreferences() async { }
|
||||
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async { }
|
||||
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async { }
|
||||
```
|
||||
|
||||
**Storage key pattern:**
|
||||
- `discovery_folders_enabled` (bool)
|
||||
- `discovery_row_{itemId}` (bool per row)
|
||||
|
||||
### 3. Provider (`lib/providers/music_assistant_provider.dart`)
|
||||
|
||||
**Already has:** `getDiscoveryFoldersWithCache()`
|
||||
|
||||
**Add method:**
|
||||
```dart
|
||||
Future<void> refreshDiscoveryFolders() async {
|
||||
// Clear cache and refetch from API
|
||||
_cacheService.clearDiscoveryFoldersCache();
|
||||
await getDiscoveryFoldersWithCache();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Home Screen (`lib/screens/new_home_screen.dart`)
|
||||
|
||||
**Changes to row rendering:**
|
||||
|
||||
Instead of a single `_discoveryFolders` list, discovery rows are integrated into `_homeRowOrder`:
|
||||
|
||||
```dart
|
||||
// Before: static list of row IDs
|
||||
List<String> _homeRowOrder = ['recent-albums', 'discover-artists', ...];
|
||||
|
||||
// After: can include discovery row IDs
|
||||
List<String> _homeRowOrder = [
|
||||
'recent-albums',
|
||||
'discovery:abc123', // Discovery row ID
|
||||
'discover-artists',
|
||||
'discovery:def456',
|
||||
...
|
||||
];
|
||||
```
|
||||
|
||||
**New method:**
|
||||
```dart
|
||||
bool _isDiscoveryRowId(String rowId) {
|
||||
return rowId.startsWith('discovery:');
|
||||
}
|
||||
|
||||
String _getDiscoveryRowItemId(String rowId) {
|
||||
return rowId.substring('discovery:'.length);
|
||||
}
|
||||
```
|
||||
|
||||
**Modify row rendering:**
|
||||
- Check if rowId is a discovery row
|
||||
- If yes, render DiscoveryRow widget with that folder
|
||||
- Cache discovery folders for quick lookup
|
||||
|
||||
### 5. Localization (`lib/l10n/app_en.arb`)
|
||||
|
||||
**Add strings:**
|
||||
```json
|
||||
{
|
||||
"discoveryMixesDescription": "Show personalized recommendations from Music Assistant",
|
||||
"discoveryRows": "Discovery rows from Music Assistant",
|
||||
"refreshDiscoveryRows": "Refresh available rows",
|
||||
"noDiscoveryRows": "No discovery rows available",
|
||||
"discoveryRowBadge": "Music Assistant"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Models (`lib/models/recommendation_folder.dart`)
|
||||
|
||||
**Add property:**
|
||||
```dart
|
||||
class RecommendationFolder {
|
||||
...
|
||||
/// Unique ID for use in row ordering and settings
|
||||
String get rowId => 'discovery:$itemId';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Settings Screen │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. Load discovery folders from Provider │
|
||||
│ 2. Load per-row enabled state from Settings │
|
||||
│ 3. Render section with toggles │
|
||||
│ 4. User toggles row → save to Settings │
|
||||
│ 5. User toggles main → save to Settings + update home row list │
|
||||
│ 6. User refreshes → Provider.fetch → update UI │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Home Screen │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. Load home row order from Settings │
|
||||
│ 2. Identify discovery row IDs (prefix "discovery:") │
|
||||
│ 3. For discovery rows: look up folder from Provider cache │
|
||||
│ 4. Render appropriate widget (DiscoveryRow or other) │
|
||||
│ 5. Handle reordering including discovery rows │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Settings Screen (No breaking changes)
|
||||
- Add discovery section to settings
|
||||
- Load and display discovery folders
|
||||
- Individual row toggles (but don't affect home screen yet)
|
||||
- Refresh button
|
||||
|
||||
### Phase 2: Home Screen Integration
|
||||
- Modify home row order to support discovery row IDs
|
||||
- Render discovery rows in main list
|
||||
- Add badge visual distinction
|
||||
|
||||
### Phase 3: Polish
|
||||
- Translations
|
||||
- Edge cases (folder disappears from MA)
|
||||
- Performance (lazy loading)
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases & Considerations
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Discovery folder no longer returned by MA | Keep user preference, hide row if not found |
|
||||
| User reorders then MA returns new folders | New folders appended to end of discovery section |
|
||||
| All discovery rows disabled | Discovery section shows but appears empty/minimized |
|
||||
| Many discovery folders (10+) | Show scrollable sublist, keep main setting compact |
|
||||
| Row ID collision | Use `discovery:{itemId}` prefix ensures uniqueness |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Settings: Add `_discoveryFolders` state
|
||||
- [ ] Settings: Add `_discoveryRowEnabled` map
|
||||
- [ ] Settings: Implement `_loadDiscoveryFolders()`
|
||||
- [ ] Settings: Implement `_buildDiscoverySection()`
|
||||
- [ ] Settings: Add refresh button handler
|
||||
- [ ] SettingsService: Add discovery row preference methods
|
||||
- [ ] Provider: Add `refreshDiscoveryFolders()`
|
||||
- [ ] HomeScreen: Support discovery row IDs in order list
|
||||
- [ ] HomeScreen: Render discovery rows with badge
|
||||
- [ ] Localization: Add new strings
|
||||
- [ ] Test: Toggle discovery rows on/off
|
||||
- [ ] Test: Reorder discovery rows
|
||||
- [ ] Test: Refresh discovery folders
|
||||
- [ ] Test: Migrate existing preference to new system
|
||||
@@ -0,0 +1,328 @@
|
||||
# Remote Connection - Future Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides guidance for implementing remote connection to Music Assistant servers via WebRTC. Remote connection allows the Ensemble app to connect to MA servers outside the local network using Nabu Casa's WebRTC signaling infrastructure.
|
||||
|
||||
## Current Status: On Hold
|
||||
|
||||
Remote connection was partially implemented but put on hold due to a critical bug where the `ma-api` data channel becomes one-way after 1-3 minutes. See `MA_WEBRTC_BUG_REPORT.md` for details.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌──────────────────────────┐
|
||||
│ Ensemble App │◄──────────────────►│ MA Server (local WS) │
|
||||
└─────────────────┘ └──────────────────────────┘
|
||||
│ │
|
||||
│ (when local unavailable) │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────────┐
|
||||
│ SignalingClient│◄──── WebSocket ───►│ signaling.music-assistant│
|
||||
└─────────────────┘ │ .io/ws │
|
||||
│ └──────────────────────────┘
|
||||
│ ICE candidates, SDP │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────────┐
|
||||
│ WebRTC Peer │◄═══ Data Channel ══►│ MA Server WebRTC │
|
||||
│ Connection │ (ma-api) │ Endpoint │
|
||||
└─────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
1. **SignalingClient** - WebSocket connection to `wss://signaling.music-assistant.io/ws`
|
||||
2. **WebRTCConnection** - Manages RTCPeerConnection and data channels
|
||||
3. **RemoteBridge** - Bridges local WebSocket API to remote WebRTC connection
|
||||
|
||||
### Data Channels
|
||||
|
||||
| Channel | Purpose | Traffic Pattern |
|
||||
|---------|---------|-----------------|
|
||||
| `ma-api` | JSON API requests/responses | High volume, large payloads |
|
||||
| `sendspin` | Audio streaming + control | Low JSON, binary audio data |
|
||||
|
||||
---
|
||||
|
||||
## Key Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/remote/signaling_client.dart` | WebSocket client for signaling server |
|
||||
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection management |
|
||||
| `lib/services/remote/remote_bridge.dart` | Bridges API calls to WebRTC |
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/services/music_assistant_api.dart` | Add remote connection fallback |
|
||||
| `lib/providers/music_assistant_provider.dart` | Expose remote connection state |
|
||||
| `pubspec.yaml` | Add `flutter_webrtc` dependency |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add Dependencies
|
||||
|
||||
```yaml
|
||||
# pubspec.yaml
|
||||
dependencies:
|
||||
flutter_webrtc: ^0.12.0 # or latest
|
||||
```
|
||||
|
||||
### Step 2: Implement SignalingClient
|
||||
|
||||
The signaling client connects to Music Assistant's signaling server and exchanges WebRTC offers/answers.
|
||||
|
||||
```dart
|
||||
class SignalingClient {
|
||||
WebSocket? _socket;
|
||||
|
||||
Future<void> connect(String serverId) async {
|
||||
_socket = await WebSocket.connect('wss://signaling.music-assistant.io/ws');
|
||||
|
||||
// Send connect request
|
||||
_send({
|
||||
'type': 'connect-request',
|
||||
'server_id': serverId,
|
||||
});
|
||||
|
||||
// Listen for messages
|
||||
_socket!.listen(_handleMessage);
|
||||
}
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
final message = jsonDecode(data);
|
||||
switch (message['type']) {
|
||||
case 'answer':
|
||||
onAnswer?.call(message['sdp']);
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
onIceCandidate?.call(message['candidate']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Implement WebRTCConnection
|
||||
|
||||
```dart
|
||||
class WebRTCConnection {
|
||||
RTCPeerConnection? _peerConnection;
|
||||
RTCDataChannel? _apiChannel;
|
||||
RTCDataChannel? _sendspinChannel;
|
||||
|
||||
Future<void> connect(SignalingClient signaling) async {
|
||||
// Create peer connection with STUN/TURN servers
|
||||
_peerConnection = await createPeerConnection({
|
||||
'iceServers': [
|
||||
{'urls': 'stun:stun.l.google.com:19302'},
|
||||
// TURN servers provided by signaling
|
||||
],
|
||||
});
|
||||
|
||||
// Create data channels BEFORE creating offer
|
||||
_apiChannel = await _peerConnection!.createDataChannel(
|
||||
'ma-api',
|
||||
RTCDataChannelInit()..ordered = true,
|
||||
);
|
||||
|
||||
_sendspinChannel = await _peerConnection!.createDataChannel(
|
||||
'sendspin',
|
||||
RTCDataChannelInit()..ordered = true,
|
||||
);
|
||||
|
||||
// Create and send offer
|
||||
final offer = await _peerConnection!.createOffer();
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
signaling.sendOffer(offer.sdp!);
|
||||
|
||||
// Handle answer from server
|
||||
signaling.onAnswer = (sdp) async {
|
||||
await _peerConnection!.setRemoteDescription(
|
||||
RTCSessionDescription(sdp, 'answer'),
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Implement RemoteBridge
|
||||
|
||||
The bridge translates between the local WebSocket API format and WebRTC data channel messages.
|
||||
|
||||
```dart
|
||||
class RemoteBridge {
|
||||
final WebRTCConnection _webrtc;
|
||||
final StreamController<String> _messageController;
|
||||
|
||||
void sendCommand(String json) {
|
||||
_webrtc.sendToApiChannel(json);
|
||||
}
|
||||
|
||||
Stream<String> get messages => _messageController.stream;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Integrate with MusicAssistantAPI
|
||||
|
||||
```dart
|
||||
class MusicAssistantAPI {
|
||||
WebSocket? _localSocket;
|
||||
RemoteBridge? _remoteBridge;
|
||||
|
||||
Future<void> connect(String host, {String? serverId}) async {
|
||||
try {
|
||||
// Try local connection first
|
||||
_localSocket = await WebSocket.connect('ws://$host/ws');
|
||||
} catch (e) {
|
||||
// Fall back to remote if serverId provided
|
||||
if (serverId != null) {
|
||||
_remoteBridge = RemoteBridge();
|
||||
await _remoteBridge!.connect(serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Implementation Details
|
||||
|
||||
### 1. Channel Creation Order
|
||||
|
||||
The client MUST create data channels BEFORE sending the WebRTC offer. The server expects the client to create:
|
||||
- `ma-api` - for API traffic
|
||||
- `sendspin` - for audio streaming (optional)
|
||||
|
||||
### 2. Health Monitoring
|
||||
|
||||
Implement aggressive health checks because data channels can silently die:
|
||||
|
||||
```dart
|
||||
// Track message counts
|
||||
int _sentCount = 0;
|
||||
int _recvCount = 0;
|
||||
DateTime _lastRecvTime = DateTime.now();
|
||||
|
||||
// Health check every 10 seconds
|
||||
void _healthCheck() {
|
||||
final timeSinceRecv = DateTime.now().difference(_lastRecvTime);
|
||||
if (timeSinceRecv.inSeconds > 30) {
|
||||
// Channel is dead, trigger reconnection
|
||||
_reconnect();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Keepalive Messages
|
||||
|
||||
Send periodic keepalive on the data channel itself (not just signaling):
|
||||
|
||||
```dart
|
||||
void _sendKeepalive() {
|
||||
_apiChannel?.send(RTCDataChannelMessage(
|
||||
jsonEncode({'type': 'ping', 'timestamp': DateTime.now().toIso8601String()}),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Flow Control
|
||||
|
||||
Monitor buffer to prevent overflow:
|
||||
|
||||
```dart
|
||||
void sendMessage(String json) {
|
||||
if (_apiChannel!.bufferedAmount > 1048576) { // 1MB
|
||||
// Queue message, don't send
|
||||
_sendQueue.add(json);
|
||||
return;
|
||||
}
|
||||
_apiChannel!.send(RTCDataChannelMessage(json));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Pitfalls
|
||||
|
||||
### 1. One-Way Channel Death (CRITICAL - UNSOLVED)
|
||||
|
||||
The `ma-api` channel stops receiving after 1-3 minutes while `sendspin` continues working. This occurs in both flutter_webrtc and WebView implementations.
|
||||
|
||||
**Workaround options:**
|
||||
- Detect via health check and force reconnection
|
||||
- Try single-channel multiplexing (see `PLAN_B_SINGLE_CHANNEL.md`)
|
||||
- Report to MA team and wait for server-side fix
|
||||
|
||||
### 2. flutter_webrtc Quirks
|
||||
|
||||
- `RTCDataChannel.state` may show "open" when actually dead
|
||||
- `bufferedAmount` accuracy varies by platform
|
||||
- `onBufferedAmountLow` callback support inconsistent
|
||||
|
||||
### 3. Android Background Throttling
|
||||
|
||||
Android aggressively throttles background WebRTC. Consider:
|
||||
- Foreground service for sustained connections
|
||||
- Reconnection on app resume
|
||||
|
||||
### 4. ICE/TURN Issues
|
||||
|
||||
- aiortc (server-side) has known TURN + DataChannel bugs
|
||||
- May need ICE restart on network changes
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Connect via remote when local unavailable
|
||||
- [ ] Verify API commands work (players/all, library queries)
|
||||
- [ ] Test sustained connection (30+ minutes)
|
||||
- [ ] Test app backgrounding and resume
|
||||
- [ ] Test network transitions (WiFi ↔ cellular)
|
||||
- [ ] Verify reconnection after channel death
|
||||
- [ ] Test sendspin audio streaming (if implemented)
|
||||
|
||||
---
|
||||
|
||||
## Useful Resources
|
||||
|
||||
- [Music Assistant Remote Access Docs](https://music-assistant.io/docs/remote-access/)
|
||||
- [flutter_webrtc Package](https://pub.dev/packages/flutter_webrtc)
|
||||
- [WebRTC Data Channel MDN](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel)
|
||||
- Signaling server: `wss://signaling.music-assistant.io/ws`
|
||||
|
||||
---
|
||||
|
||||
## Files in This Directory
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `FUTURE_IMPLEMENTATION_GUIDE.md` | This file - implementation guide |
|
||||
| `MA_WEBRTC_BUG_REPORT.md` | Bug report for MA team |
|
||||
| `WEBRTC_DISCONNECT_FIXES.md` | All fixes attempted |
|
||||
| `WEBRTC_PROGRESS.md` | Progress log |
|
||||
| `PLAN_B_SINGLE_CHANNEL.md` | Alternative single-channel approach |
|
||||
| `webrtc-failure-logs.txt` | Raw failure logs |
|
||||
| `console-export-*.log` | Browser console exports |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start for Future Implementation
|
||||
|
||||
1. Read this guide fully
|
||||
2. Review `WEBRTC_DISCONNECT_FIXES.md` for all attempted fixes
|
||||
3. Check if MA team has fixed server-side issues
|
||||
4. Start with basic implementation, add health monitoring early
|
||||
5. Test extensively before shipping - the failure mode is subtle
|
||||
@@ -0,0 +1,127 @@
|
||||
# WebRTC Data Channel Failure on Android - Bug Report
|
||||
|
||||
## Summary
|
||||
|
||||
When connecting to Music Assistant via WebRTC remote access (Nabu Casa), the `ma-api` data channel becomes **one-way** (can send, cannot receive) after 1-3 minutes of use, while the `sendspin` channel continues working normally.
|
||||
|
||||
## Environment
|
||||
|
||||
- **MA Server**: 2.8.0b7 (Home Assistant add-on)
|
||||
- **Client**: Android app (Flutter) connecting via Nabu Casa remote access
|
||||
- **Remote Access**: WebRTC via `wss://signaling.music-assistant.io/ws`
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Connect to MA server via WebRTC remote access (not local network)
|
||||
2. Use the app normally - browse library, view players
|
||||
3. After 1-3 minutes, the connection degrades
|
||||
4. Commands time out, but signaling ping/pong continues working
|
||||
|
||||
## Observed Behavior
|
||||
|
||||
### Timeline from logs:
|
||||
|
||||
```
|
||||
07:14:20 - Working normally
|
||||
Health: API sent:51 recv:54 (balanced)
|
||||
|
||||
07:15:57 - Receive stops
|
||||
Health: API sent:97 recv:97, lastRecv:12s ago
|
||||
|
||||
07:16:02 - Timeouts begin
|
||||
"TimeoutException: Command timeout: players/all"
|
||||
|
||||
07:16:17 - Sendspin still works!
|
||||
"Sendspin: Received server/time response"
|
||||
Health: API sent:114 recv:97, lastRecv:27s ago
|
||||
|
||||
07:17:30 - Forced reconnect
|
||||
Health: API sent:137 recv:97, lastRecv:42s ago
|
||||
"Health check detected stale connection"
|
||||
```
|
||||
|
||||
### Key Observation
|
||||
|
||||
| Channel | Traffic Pattern | Status |
|
||||
|---------|-----------------|--------|
|
||||
| `ma-api` | High (10+ msg/s, large payloads) | **FAILS** - stops receiving |
|
||||
| `sendspin` | Low (~1 msg/5s, small payloads) | **SURVIVES** - continues working |
|
||||
| Signaling | Ping/pong every 15s | **SURVIVES** - continues working |
|
||||
|
||||
The WebRTC peer connection remains in "connected" state. ICE connection shows "completed". Only the ma-api data channel stops receiving.
|
||||
|
||||
## Testing Performed
|
||||
|
||||
### Test 1: flutter_webrtc (Native WebRTC)
|
||||
- **Result**: ma-api channel dies after 1-3 minutes
|
||||
- **Sendspin**: Continues working
|
||||
|
||||
### Test 2: WebView-based WebRTC (Browser engine)
|
||||
- **Hypothesis**: Use Android WebView's browser-native WebRTC, since MA Web UI works
|
||||
- **Implementation**: Hidden InAppWebView running JavaScript WebRTC engine
|
||||
- **Result**: **Same failure mode** - ma-api dies, sendspin survives
|
||||
- **Conclusion**: Not a flutter_webrtc bug
|
||||
|
||||
### Test 3: Various Data Channel Settings
|
||||
- Tried `ordered: false` with `maxRetransmits: 3`
|
||||
- Tried letting server create channels (failed - server expects client to create)
|
||||
- No improvement
|
||||
|
||||
## Analysis
|
||||
|
||||
The identical failure in both native and WebView WebRTC implementations suggests the issue is:
|
||||
|
||||
1. **Server-side** - Different handling of ma-api vs sendspin channels
|
||||
2. **TURN relay** - High-traffic channel may hit limits
|
||||
3. **Not client-specific** - Both implementations fail identically
|
||||
|
||||
### Questions for MA Team
|
||||
|
||||
1. Are the two data channels (`ma-api`, `sendspin`) created with different settings?
|
||||
2. Is there any server-side buffering or rate limiting that could affect high-traffic channels?
|
||||
3. Does the MA Web UI use different data channel configurations?
|
||||
4. Are there known issues with the TURN relay for sustained high-traffic channels?
|
||||
|
||||
## Comparison: MA Web UI vs Android App
|
||||
|
||||
| Aspect | MA Web UI | Android App |
|
||||
|--------|-----------|-------------|
|
||||
| Platform | Browser tab (visible, active) | Background-capable app |
|
||||
| WebRTC | Browser native | flutter_webrtc / WebView |
|
||||
| Result | Works | Fails after 1-3 min |
|
||||
|
||||
The MA Web UI runs in a **visible, active browser tab**. Android may throttle background apps differently, but this doesn't explain why sendspin survives while ma-api dies on the same connection.
|
||||
|
||||
## Logs
|
||||
|
||||
### Health Check Pattern (shows channel death)
|
||||
```
|
||||
07:14:25 API sent:51 recv:54, lastRecv:2s ago <- Working
|
||||
07:15:35 API sent:87 recv:91, lastRecv:2s ago <- Working
|
||||
07:16:05 API sent:108 recv:97, lastRecv:17s ago <- DYING (gap growing)
|
||||
07:16:15 API sent:114 recv:97, lastRecv:27s ago <- DEAD
|
||||
07:16:45 API sent:130 recv:97, lastRecv:27s ago <- Still sending, no receive
|
||||
07:17:30 API sent:137 recv:97, lastRecv:42s ago <- Forced reconnect
|
||||
```
|
||||
|
||||
### Sendspin Still Working During Failure
|
||||
```
|
||||
07:16:17 Sendspin: Received server/time response <- Still alive!
|
||||
07:16:47 Sendspin: Received server/time response <- Still alive!
|
||||
```
|
||||
|
||||
## Requested Information
|
||||
|
||||
1. Data channel creation code/settings for both channels
|
||||
2. Any server-side differences in channel handling
|
||||
3. TURN server configuration details
|
||||
4. Whether this has been reported by other Android/mobile clients
|
||||
|
||||
## Workaround
|
||||
|
||||
Currently using local WebSocket connection when on same network. Remote access via WebRTC is unusable for sustained use.
|
||||
|
||||
---
|
||||
|
||||
**Reporter**: Ensemble Android App Developer
|
||||
**Date**: 2026-01-24
|
||||
@@ -0,0 +1,149 @@
|
||||
# Plan B: Single Data Channel Multiplexing
|
||||
|
||||
## Why This Might Be Needed
|
||||
|
||||
If upgrading flutter_webrtc doesn't fix the one-way channel failure, the root cause is likely a bug in how flutter_webrtc (or underlying libwebrtc) handles **multiple data channels**.
|
||||
|
||||
Evidence:
|
||||
- MA Web UI works fine with local playback (same server, browser WebRTC)
|
||||
- Our app uses flutter_webrtc which has known issues:
|
||||
- [Issue #1428](https://github.com/flutter-webrtc/flutter-webrtc/issues/1428): Threading issues causing data loss
|
||||
- [Issue #974](https://github.com/flutter-webrtc/flutter-webrtc/issues/974): EventChannel intermittently fails
|
||||
- Pattern matches: second channel survives while first dies
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ WebRTC Peer Connection │
|
||||
├────────────────────┬────────────────────────────┤
|
||||
│ ma-api channel │ sendspin channel │
|
||||
│ (JSON API) │ (JSON + binary audio) │
|
||||
│ SCTP stream 0 │ SCTP stream 1 │
|
||||
└────────────────────┴────────────────────────────┘
|
||||
```
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ WebRTC Peer Connection │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ unified channel │
|
||||
│ (multiplexed: API + Sendspin text + binary) │
|
||||
│ SCTP stream 0 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Multiplexing Protocol
|
||||
|
||||
### Message Framing
|
||||
|
||||
Each message has a 1-byte type prefix:
|
||||
|
||||
| Prefix | Type | Content |
|
||||
|--------|------|---------|
|
||||
| 0x00 | API | JSON text (MA API request/response) |
|
||||
| 0x01 | Sendspin Text | JSON text (Sendspin control messages) |
|
||||
| 0x02 | Sendspin Binary | Raw bytes (PCM audio data) |
|
||||
|
||||
### Implementation
|
||||
|
||||
#### Sending (Client → Server)
|
||||
|
||||
```dart
|
||||
void sendApiMessage(String json) {
|
||||
final prefix = Uint8List.fromList([0x00]);
|
||||
final payload = utf8.encode(json);
|
||||
final message = Uint8List(1 + payload.length);
|
||||
message[0] = 0x00;
|
||||
message.setRange(1, message.length, payload);
|
||||
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
|
||||
}
|
||||
|
||||
void sendSendspinText(String json) {
|
||||
final prefix = Uint8List.fromList([0x01]);
|
||||
final payload = utf8.encode(json);
|
||||
final message = Uint8List(1 + payload.length);
|
||||
message[0] = 0x01;
|
||||
message.setRange(1, message.length, payload);
|
||||
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
|
||||
}
|
||||
|
||||
void sendSendspinBinary(Uint8List audio) {
|
||||
final message = Uint8List(1 + audio.length);
|
||||
message[0] = 0x02;
|
||||
message.setRange(1, message.length, audio);
|
||||
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
|
||||
}
|
||||
```
|
||||
|
||||
#### Receiving (Server → Client)
|
||||
|
||||
```dart
|
||||
void _onMessage(RTCDataChannelMessage message) {
|
||||
final data = message.binary;
|
||||
if (data == null || data.isEmpty) return;
|
||||
|
||||
final type = data[0];
|
||||
final payload = data.sublist(1);
|
||||
|
||||
switch (type) {
|
||||
case 0x00: // API
|
||||
final json = utf8.decode(payload);
|
||||
_handleApiMessage(json);
|
||||
break;
|
||||
case 0x01: // Sendspin text
|
||||
final json = utf8.decode(payload);
|
||||
_handleSendspinText(json);
|
||||
break;
|
||||
case 0x02: // Sendspin binary
|
||||
_handleSendspinBinary(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Changes Required
|
||||
|
||||
The Music Assistant server would need to:
|
||||
1. Accept a single "unified" data channel instead of separate ma-api and sendspin channels
|
||||
2. Multiplex responses using the same framing protocol
|
||||
3. This requires changes to the MA remote connection code
|
||||
|
||||
**Alternative**: Keep server unchanged, do multiplexing in a proxy layer on the client.
|
||||
|
||||
## Client-Only Approach (No Server Changes)
|
||||
|
||||
If we can't change the server, we could:
|
||||
|
||||
1. Still create two channels to the server (ma-api + sendspin)
|
||||
2. But internally route them through a single "virtual" channel for flutter_webrtc
|
||||
3. This is more complex and may not avoid the underlying bug
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `webrtc_connection.dart` | Single channel creation, multiplexing logic |
|
||||
| `remote_bridge.dart` | Route messages based on type prefix |
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Server compatibility**: Requires MA server changes or complex client proxy
|
||||
2. **Performance**: Extra byte per message, decode overhead
|
||||
3. **Binary handling**: Must handle all messages as binary (not text)
|
||||
|
||||
## Testing Plan
|
||||
|
||||
1. Implement client-side changes
|
||||
2. Test with modified server (if available) or mock
|
||||
3. Verify both API and Sendspin traffic flows correctly
|
||||
4. Long-duration stability test
|
||||
|
||||
## Decision Point
|
||||
|
||||
Try this approach if:
|
||||
- flutter_webrtc 0.14.x doesn't fix the issue
|
||||
- The one-way failure pattern continues
|
||||
- We've confirmed the issue is multi-channel specific
|
||||
@@ -0,0 +1,406 @@
|
||||
# WebRTC Data Channel Disconnect Fixes
|
||||
|
||||
## Document Purpose
|
||||
Track all fixes attempted for the WebRTC data channel disconnection issues in Ensemble's remote connection feature. This document should be referenced when debugging future disconnection problems.
|
||||
|
||||
---
|
||||
|
||||
## Problem Summary
|
||||
|
||||
The WebRTC data channel between the Ensemble mobile app and Music Assistant server can fail in several ways:
|
||||
|
||||
1. **One-way failure**: Data channel appears "open" but only sends work, receives stop
|
||||
2. **Silent disconnect**: No error events fire, channel just stops working
|
||||
3. **ICE failure**: ICE connection fails, properly detected but causes disconnect
|
||||
4. **Signaling disconnect**: Signaling WebSocket closes, causing ICE/WebRTC to fail
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection, data channel management |
|
||||
| `lib/services/remote/remote_bridge.dart` | WebSocket bridge, health monitoring, reconnection logic |
|
||||
| `lib/services/remote/signaling_client.dart` | Signaling server WebSocket connection |
|
||||
|
||||
---
|
||||
|
||||
## Implemented Fixes (Chronological)
|
||||
|
||||
### Fix 1: Data Channel Keepalive (Original)
|
||||
**Problem**: Data channel could silently die without any error events firing.
|
||||
|
||||
**Solution**: Added periodic keepalive ping/pong on the data channel itself.
|
||||
|
||||
**Code** (`webrtc_connection.dart`):
|
||||
- `_keepaliveTimer`: Runs every 10 seconds
|
||||
- `_checkKeepalive()`: Checks time since last data received
|
||||
- `_sendKeepalivePing()`: Sends JSON ping message
|
||||
- `_recordDataReceived()`: Called on every message receipt
|
||||
|
||||
**Constants**:
|
||||
```dart
|
||||
static const _keepaliveInterval = Duration(seconds: 10);
|
||||
static const _keepaliveTimeout = Duration(seconds: 20); // Reduced from 30
|
||||
static const _maxKeepaliveMisses = 2; // Reduced from 3
|
||||
```
|
||||
|
||||
### Fix 2: ICE Recovery
|
||||
**Problem**: ICE connection could temporarily disconnect and might auto-recover.
|
||||
|
||||
**Solution**: Added ICE recovery timer before declaring failure.
|
||||
|
||||
**Code** (`webrtc_connection.dart`):
|
||||
- `_startIceRecovery()`: Starts 5-second timer when ICE disconnects
|
||||
- `_cancelIceRecovery()`: Cancels if ICE recovers on its own
|
||||
- `_attemptIceRestart()`: Calls `restartIce()` if recovery timer expires
|
||||
|
||||
### Fix 3: Flow Control (Buffer Overflow Prevention)
|
||||
**Problem**: Fire-and-forget sends could overflow the 16MB SCTP buffer, causing silent channel death.
|
||||
|
||||
**Solution**: Added send-side flow control with buffering.
|
||||
|
||||
**Code** (`webrtc_connection.dart`):
|
||||
```dart
|
||||
static const _maxBufferedAmount = 1048576; // 1MB threshold
|
||||
static const _bufferLowThreshold = 65536; // 64KB resume threshold
|
||||
bool _sendPaused = false;
|
||||
final _sendQueue = <String>[];
|
||||
```
|
||||
|
||||
**Modified `sendRaw()`**:
|
||||
- Checks `bufferedAmount` before sending
|
||||
- Queues messages if buffer > 1MB
|
||||
- `onBufferedAmountLow` callback drains queue
|
||||
|
||||
### Fix 4: Health Check Stale Detection
|
||||
**Problem**: Health check could see "30s since last data" but not trigger reconnection.
|
||||
|
||||
**Solution**: Reduced stale detection threshold from 45s to 30s.
|
||||
|
||||
**Code** (`remote_bridge.dart`):
|
||||
```dart
|
||||
if (_state == RemoteBridgeState.connected && timeSinceLastData > 30 && !_isReconnecting) {
|
||||
// Force reconnection
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 5: Reduced Keepalive Timeouts
|
||||
**Problem**: 30s timeout × 3 misses = 90s minimum before detection.
|
||||
|
||||
**Solution**: Reduced to 20s timeout × 2 misses = ~40s detection.
|
||||
|
||||
### Fix 6: Recv Stagnation Detection
|
||||
**Problem**: One-way failure where recv stops but sent continues not detected quickly enough.
|
||||
|
||||
**Solution**: Track recv count across health checks. If recv stuck while sent increases for 2 consecutive checks (20s), force reconnection.
|
||||
|
||||
**Code** (`remote_bridge.dart`):
|
||||
```dart
|
||||
// Track across health checks
|
||||
int _lastHealthCheckRecvCount = 0;
|
||||
int _lastHealthCheckSentCount = 0;
|
||||
int _recvStagnationCount = 0;
|
||||
|
||||
// In health check:
|
||||
if (recvStuck && sentIncreased) {
|
||||
_recvStagnationCount++;
|
||||
if (_recvStagnationCount >= 2) {
|
||||
// Force reconnection - channel is one-way
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Detection time**: ~20 seconds (2 × 10s health check interval)
|
||||
|
||||
---
|
||||
|
||||
## Log Patterns to Watch For
|
||||
|
||||
### Healthy Connection
|
||||
```
|
||||
Health check - API sent:93 recv:88 ... lastRecv:0s ago
|
||||
```
|
||||
- `sent` and `recv` both increasing
|
||||
- `lastRecv` low (< 5s typically)
|
||||
|
||||
### One-Way Failure (CRITICAL)
|
||||
```
|
||||
Health check - API sent:206 recv:163 ... lastRecv:30s ago
|
||||
Health check - API sent:218 recv:163 ... lastRecv:40s ago
|
||||
```
|
||||
- `sent` increases, `recv` STUCK
|
||||
- `lastRecv` keeps increasing
|
||||
- Data channel still shows "open"
|
||||
|
||||
### Signaling Disconnect
|
||||
```
|
||||
WebRTC: Signaling disconnected
|
||||
... (6-16 seconds later) ...
|
||||
WebRTC: ICE connection state: RTCIceConnectionStateDisconnected
|
||||
WebRTC: ICE connection state: RTCIceConnectionStateFailed
|
||||
```
|
||||
|
||||
### Network Failure (DNS)
|
||||
```
|
||||
Signaling: WebSocket closed
|
||||
... (reconnection attempt) ...
|
||||
Signaling: Failed to connect: SocketException: Failed host lookup: 'signaling.music-assistant.io'
|
||||
```
|
||||
This indicates the phone lost network connectivity entirely.
|
||||
|
||||
### Reconnection Storm (Signaling Instability)
|
||||
```
|
||||
Signaling: Connecting to wss://signaling.music-assistant.io/ws
|
||||
Signaling: Sending connect-request
|
||||
Signaling: WebSocket closed ← Closes within 100-500ms
|
||||
WebRTC: Signaling disconnected
|
||||
RemoteBridge: Scheduling reconnect attempt X/10 in Ys
|
||||
```
|
||||
Rapid reconnection loop - signaling never stays connected long enough.
|
||||
|
||||
### Flow Control Engaged
|
||||
```
|
||||
WebRTC: ⚠️ Buffer full (1234567 bytes) or paused - queueing message
|
||||
WebRTC: Buffer drained to 12345 bytes - draining 5 queued messages
|
||||
WebRTC: ✅ Send queue drained (5 messages) - resuming normal sends
|
||||
```
|
||||
|
||||
### Keepalive Detection
|
||||
```
|
||||
WebRTC: ⚠️ Keepalive timeout! No data for 22s (missed: 1/2)
|
||||
WebRTC: ❌ Data channel appears dead - triggering reconnection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Limitations
|
||||
|
||||
### 1. flutter_webrtc Limitations
|
||||
- `RTCDataChannel.state` may show "open" when actually dead (Issue #843)
|
||||
- `bufferedAmount` may not be accurate on all platforms
|
||||
- `onBufferedAmountLow` callback support varies
|
||||
|
||||
### 2. SCTP Receive Window
|
||||
- If server sends faster than client processes, receive window fills
|
||||
- This causes server-side to stop sending, client sees no data
|
||||
- No way to monitor receive-side buffer from client
|
||||
|
||||
### 3. TURN Server Issues
|
||||
- aiortc (server-side) has known issues with TURN + DataChannel (#1156)
|
||||
- Data channel may fail silently when using TURN relay
|
||||
|
||||
### 4. Network Transitions
|
||||
- WiFi → Cellular or vice versa can cause ICE to fail
|
||||
- App backgrounding on mobile can close WebSocket/WebRTC
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Categories
|
||||
|
||||
### Category A: Network Issues (Not Code Bugs)
|
||||
These are environmental issues, not bugs in our code:
|
||||
- **DNS failure**: `Failed host lookup` - Phone lost internet
|
||||
- **WiFi/Cellular switch**: Network changed, connections dropped
|
||||
- **Server unreachable**: Signaling server down or blocked
|
||||
- **Firewall/NAT issues**: TURN relay needed but unavailable
|
||||
|
||||
**Evidence**: Signaling WebSocket closes, DNS lookups fail, reconnection attempts fail repeatedly.
|
||||
|
||||
### Category B: WebRTC/SCTP Issues (Partial Code Control)
|
||||
These can be mitigated but not fully prevented:
|
||||
- **One-way channel death**: Channel appears open but data stops flowing
|
||||
- **Buffer overflow**: Too much data sent, SCTP buffer exceeded
|
||||
- **ICE failure**: Network path lost, no recovery possible
|
||||
|
||||
**Evidence**: Data channel shows "open" but `recv` stuck, `lastRecv` increasing.
|
||||
|
||||
### Category C: Code Bugs (Fixable)
|
||||
Issues in our implementation:
|
||||
- **Detection too slow**: Timeouts too long, misses too many
|
||||
- **Recovery not triggered**: State checks wrong, reconnection not started
|
||||
- **Resource leaks**: Timers not cancelled, connections not cleaned up
|
||||
|
||||
**Evidence**: Logs show problem but no action taken, or wrong action taken.
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
When investigating a disconnect:
|
||||
|
||||
1. **Find the last successful response**:
|
||||
```
|
||||
grep "WebRTC API → WS" logs.txt | head -5
|
||||
```
|
||||
|
||||
2. **Check health check progression**:
|
||||
```
|
||||
grep "Health check" logs.txt
|
||||
```
|
||||
Look for `recv` getting stuck while `sent` increases.
|
||||
|
||||
3. **Check for signaling issues**:
|
||||
```
|
||||
grep -i "signaling" logs.txt
|
||||
```
|
||||
|
||||
4. **Check ICE state changes**:
|
||||
```
|
||||
grep "ICE connection state" logs.txt
|
||||
```
|
||||
|
||||
5. **Check for keepalive detection**:
|
||||
```
|
||||
grep -i "keepalive" logs.txt
|
||||
```
|
||||
|
||||
6. **Check buffer status**:
|
||||
```
|
||||
grep -E "(buffer|queue|paused)" logs.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Potential Future Improvements
|
||||
|
||||
### 1. Server-Side Backpressure
|
||||
MA protocol doesn't have explicit backpressure. Could add:
|
||||
- Client-side request throttling
|
||||
- Application-level ACKs for critical messages
|
||||
|
||||
### 2. More Aggressive Detection
|
||||
- Track recv count across health checks, detect stagnation
|
||||
- Shorter keepalive interval (5s instead of 10s)
|
||||
|
||||
### 3. Proactive Health Signals
|
||||
- Monitor `bufferedAmount` trend (increasing = problem)
|
||||
- Track round-trip latency of keepalive pings
|
||||
|
||||
### 4. Connection Quality Metrics
|
||||
- Expose packet loss, jitter from WebRTC stats
|
||||
- Use to predict imminent failure
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
| Scenario | How to Test | Expected Behavior |
|
||||
|----------|-------------|-------------------|
|
||||
| Normal usage | Browse library, play music | No warnings |
|
||||
| Heavy sync | Pull-to-refresh library | Buffer may queue, should drain |
|
||||
| Long session | Leave connected 2+ hours | Should stay connected |
|
||||
| Rapid navigation | Quick tab switching | Brief queue usage, recovers |
|
||||
| Network change | WiFi → Cellular | Should detect and reconnect |
|
||||
| App background | Background for 5+ min | May disconnect, should reconnect |
|
||||
|
||||
---
|
||||
|
||||
## Observed Failure Patterns
|
||||
|
||||
### Pattern 1: One-Way Death After Library Sync
|
||||
**Observed**: 2026-01-23 (multiple times)
|
||||
|
||||
```
|
||||
17:13:06.175 - Last response (playlists sync)
|
||||
17:13:06.264 - Local batch save
|
||||
-- 5 seconds silence --
|
||||
17:13:11.005 - Send players/all → NO RESPONSE
|
||||
17:13:13.914 - ICE state: completed (looks healthy!)
|
||||
17:13:28 - Keepalive detects timeout
|
||||
17:13:38 - Reconnection triggered
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- Often happens after library sync (large batch responses)
|
||||
- ICE shows "completed" even though data channel is dead
|
||||
- Signaling ping/pong continues working
|
||||
- Only data channel receive is affected
|
||||
|
||||
**Hypothesis**:
|
||||
- Server-side SCTP buffer exhausted from sending large responses?
|
||||
- Client receive window full, server blocks?
|
||||
- flutter_webrtc onMessage callback stops firing?
|
||||
|
||||
### Pattern 2: Reconnection Storm / Signaling Instability
|
||||
**Observed**: 2026-01-23 17:30-17:31
|
||||
|
||||
```
|
||||
17:31:05.093 - Signaling: Connecting
|
||||
17:31:05.216 - Signaling: Sending connect-request
|
||||
17:31:05.218 - Signaling: WebSocket closed (123ms later!)
|
||||
17:31:05.218 - WebRTC: Signaling disconnected
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- Signaling WebSocket closes almost immediately after connect
|
||||
- Multiple rapid reconnection attempts (storm)
|
||||
- Connection never stable long enough for data flow
|
||||
- Often happens after network instability
|
||||
- Artist detail, albums etc don't load because connection keeps failing
|
||||
|
||||
**Possible Causes**:
|
||||
- Network instability (WiFi/cellular issues)
|
||||
- Signaling server rate limiting
|
||||
- DNS resolution failures
|
||||
- Server rejecting rapid reconnection attempts
|
||||
|
||||
**Side Effects**:
|
||||
- UI shows errors like "Not connected to Music Assistant server"
|
||||
- Artist/album detail screens fail to load
|
||||
- Players list empty or stale
|
||||
|
||||
### Pattern 3: API Channel Dies While Sendspin Lives
|
||||
**Observed**: 2026-01-23 17:53
|
||||
|
||||
```
|
||||
17:53:35.755 - Send album_tracks request → NO RESPONSE
|
||||
17:53:40.596 - Health check: recv stuck at 197, sent increased
|
||||
17:53:55.204 - Sendspin channel RECEIVES server/time response!
|
||||
17:54:02.790 - Keepalive detects dead API channel
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- API data channel (ma-api) stops receiving
|
||||
- Sendspin data channel continues working normally
|
||||
- Same WebRTC peer connection, different data channels
|
||||
- This proves it's NOT a network issue - Sendspin traffic flows fine
|
||||
|
||||
**Hypothesis**:
|
||||
- flutter_webrtc bug where `onMessage` callback stops firing for one channel
|
||||
- Possible memory/GC issue with the callback
|
||||
- Server-side might be sending to wrong channel?
|
||||
|
||||
**Key Evidence**:
|
||||
- Recv count stays at 197 for API
|
||||
- Sendspin recv count increases (7→8)
|
||||
- Both channels use same peer connection
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Date | Build | Changes |
|
||||
|------|-------|---------|
|
||||
| 2026-01-23 | ~v49 | Initial flow control implementation |
|
||||
| 2026-01-23 | ~v49 | Reduced keepalive timeout 30→20s, misses 3→2 |
|
||||
| 2026-01-23 | ~v49 | Reduced health check stale threshold 45→30s |
|
||||
| 2026-01-23 | ~v49 | Fixed excessive "queue drained" logging |
|
||||
| 2026-01-23 | ~v49 | Created WEBRTC_DISCONNECT_FIXES.md documentation |
|
||||
| 2026-01-23 | ~v49 | Added recv stagnation detection (20s detection) |
|
||||
| 2026-01-23 | ~v49 | Documented Pattern 2: Reconnection storm / signaling instability |
|
||||
| 2026-01-23 | ~v49 | Fixed race condition in _scheduleReconnect (multiple simultaneous calls) |
|
||||
| 2026-01-23 | ~v49 | Added WebSocket close code/reason logging to signaling |
|
||||
| 2026-01-23 | ~v49 | **CRITICAL FIX**: _reconnectWebRTC() was returning immediately due to _isReconnecting already being true |
|
||||
| 2026-01-23 | ~v49 | Fixed old WebRTC callbacks firing during reconnection cleanup (clear callbacks before disconnect) |
|
||||
| 2026-01-23 | ~v49 | Fixed onStateChanged handler calling _handleWebRTCDisconnection during active reconnection |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- flutter_webrtc Issue #843: Data channel state unreliable
|
||||
- aiortc Issue #1156: TURN + DataChannel bugs
|
||||
- WebRTC SCTP: Max buffer is 16MB, recommended threshold 1MB
|
||||
- Music Assistant remote protocol: No explicit flow control
|
||||
@@ -0,0 +1,118 @@
|
||||
# WebRTC Remote Connection Progress
|
||||
|
||||
## Problem Statement
|
||||
The flutter_webrtc library has reliability issues causing data channel failures during remote connections via Nabu Casa. The ma-api data channel becomes one-way (can send, but receives stop) after some time, while the sendspin channel continues working.
|
||||
|
||||
## Approaches Tried
|
||||
|
||||
### 1. Original Implementation (flutter_webrtc)
|
||||
- **Status**: Fails
|
||||
- **Issue**: ma-api data channel goes one-way after some time
|
||||
- **Files**: `lib/services/remote/webrtc_connection.dart`
|
||||
|
||||
### 2. WebView-based WebRTC Implementation
|
||||
- **Status**: Same failure mode
|
||||
- **Hypothesis**: Use browser-native WebRTC via InAppWebView, since MA Web UI works
|
||||
- **Result**: Same one-way channel death occurs
|
||||
- **Key Finding**: This proves the issue is NOT specific to flutter_webrtc
|
||||
|
||||
## Key Observations
|
||||
|
||||
From the logs:
|
||||
```
|
||||
lastRecv:26s ago <- No messages received in 26 seconds
|
||||
API sent:674 recv:618 <- Gap growing, responses stopped
|
||||
```
|
||||
|
||||
- Signaling ping/pong continues working
|
||||
- Sendspin channel continues receiving (`server/time` responses)
|
||||
- Only ma-api channel becomes one-way
|
||||
- WebRTC peer connection itself remains "connected"
|
||||
|
||||
## Differences from Working MA Web UI
|
||||
|
||||
The MA Web UI runs in a **visible, active browser tab**. Our implementations:
|
||||
1. flutter_webrtc: Native WebRTC in background app
|
||||
2. WebView: Browser WebRTC in **hidden 1x1 pixel WebView**
|
||||
|
||||
Android aggressively throttles hidden WebViews to save battery.
|
||||
|
||||
## Current Implementation (WebView)
|
||||
|
||||
### Files Created
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/remote/webview_webrtc_connection.dart` | Dart WebView wrapper |
|
||||
| `assets/webrtc_engine.js` | JavaScript WebRTC engine |
|
||||
|
||||
### Files Modified
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `pubspec.yaml` | Added `flutter_inappwebview: ^6.0.0` |
|
||||
| `lib/services/remote/remote_bridge.dart` | Uses `WebViewWebRTCConnection` |
|
||||
| `lib/providers/music_assistant_provider.dart` | Exposes WebView widget |
|
||||
| `lib/main.dart` | Adds WebView to widget tree |
|
||||
|
||||
### Architecture
|
||||
```
|
||||
MusicAssistantAPI -> localhost WebSocket -> RemoteBridge
|
||||
|
|
||||
WebViewWebRTCConnection
|
||||
|
|
||||
Hidden InAppWebView
|
||||
|
|
||||
JavaScript WebRTC Engine
|
||||
|
|
||||
MA Server via TURN
|
||||
```
|
||||
|
||||
## Current Fixes Being Tested
|
||||
|
||||
### 1. Prevent WebView Throttling
|
||||
- Changed WebView position from off-screen (-10,-10) to on-screen (0,0) with Opacity(0)
|
||||
- Added various InAppWebViewSettings to prevent background throttling
|
||||
|
||||
### 2. Data Channel Settings
|
||||
- Changed from `ordered: true` to `ordered: false` with `maxRetransmits: 3`
|
||||
- Hypothesis: Ordered delivery may cause buffer bloat issues
|
||||
|
||||
### 3. Buffer Monitoring
|
||||
- Added `bufferedAmount` checking in JavaScript
|
||||
- Logs warning when buffer exceeds 1MB
|
||||
- Added `getChannelHealth()` function for diagnostics
|
||||
|
||||
### 4. Reduced Logging
|
||||
- Removed per-message logging (was filling logs too fast)
|
||||
- Keep only state changes, errors, and warnings
|
||||
|
||||
## Experiments Completed
|
||||
|
||||
### 5. Server-Created Channels (Failed)
|
||||
- **Hypothesis**: Let server create data channels to avoid ID collisions
|
||||
- **Result**: Connection timeout - server expects CLIENT to create channels
|
||||
- **Learning**: Client must create channels before sending offer
|
||||
|
||||
## Next Steps to Try
|
||||
|
||||
1. **Make WebView actually visible** - Android may still throttle Opacity(0) WebView
|
||||
2. **Single data channel** - Use only ma-api, disable sendspin
|
||||
3. **Periodic channel recreation** - Detect stale channel and recreate
|
||||
4. **Report to MA team** - Browser WebRTC also fails, suggesting server-side issue
|
||||
|
||||
## Test APK Location
|
||||
`/home/chris/Ensemble/ensemble-webview-webrtc.apk`
|
||||
|
||||
## Useful Debug Commands
|
||||
|
||||
Check channel health (in JS console or via Dart):
|
||||
```javascript
|
||||
getChannelHealth()
|
||||
// Returns: { peerConnection, iceConnection, apiChannel, apiBuffered, sendspinChannel, sendspinBuffered, signalingConnected }
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Why does sendspin channel survive when ma-api dies?
|
||||
2. Is there a difference in how MA server handles the two channels?
|
||||
3. Does MA Web UI use different data channel settings?
|
||||
4. Is there a TURN relay issue specific to high-traffic channels?
|
||||
@@ -0,0 +1,2 @@
|
||||
New package ID (com.collotsspot.ensemble) - reinstall required.
|
||||
Prepares for F-Droid and Play Store submission.
|
||||
@@ -0,0 +1 @@
|
||||
Add fastlane metadata for F-Droid.
|
||||
@@ -0,0 +1 @@
|
||||
Disable dependency metadata in APK for F-Droid compliance.
|
||||
@@ -0,0 +1,13 @@
|
||||
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
|
||||
|
||||
Features:
|
||||
* Stream music directly to your phone with background playback
|
||||
* Control any speaker or device connected to Music Assistant
|
||||
* Multi-room grouping capabilities
|
||||
* Queue management with drag-to-reorder
|
||||
* Background playback with media notifications
|
||||
* Fast ~300ms response for play/pause controls
|
||||
|
||||
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
|
||||
|
||||
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 909 KiB |
@@ -0,0 +1 @@
|
||||
Unofficial mobile client for Music Assistant
|
||||
@@ -0,0 +1 @@
|
||||
Ensemble
|
||||
@@ -32,6 +32,12 @@ class HeroTags {
|
||||
/// Podcast title hero tag prefix
|
||||
static const String podcastTitle = 'podcast_title_';
|
||||
|
||||
/// Podcast episode cover hero tag prefix
|
||||
static const String podcastEpisodeCover = 'podcast_episode_cover_';
|
||||
|
||||
/// Podcast episode title hero tag prefix
|
||||
static const String podcastEpisodeTitle = 'podcast_episode_title_';
|
||||
|
||||
/// Playlist cover hero tag prefix
|
||||
static const String playlistCover = 'playlist_cover_';
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ class Timings {
|
||||
/// Player state polling interval (for selected player updates)
|
||||
static const Duration playerPollingInterval = Duration(seconds: 5);
|
||||
|
||||
/// Notification position update interval (for updating playback position in notification)
|
||||
static const Duration notificationPositionUpdate = Duration(seconds: 1);
|
||||
|
||||
/// Local player state report interval
|
||||
/// Note: Sendspin protocol only requires state on changes, not periodic reports.
|
||||
/// This is mainly for local UI position tracking. Set to 5s to reduce server traffic.
|
||||
@@ -54,3 +57,62 @@ class LibraryConstants {
|
||||
/// Default limit for recent/random items
|
||||
static const int defaultRecentLimit = 10;
|
||||
}
|
||||
|
||||
/// Image/artwork size constants
|
||||
class ImageSizes {
|
||||
ImageSizes._();
|
||||
|
||||
/// Default image size for artwork (256x256)
|
||||
static const int defaultSize = 256;
|
||||
|
||||
/// High resolution image size for player/overlay (512x512)
|
||||
static const int highRes = 512;
|
||||
|
||||
/// Maximum image size for large displays
|
||||
static const int max = 1024;
|
||||
}
|
||||
|
||||
/// Volume control constants
|
||||
class VolumeConstants {
|
||||
VolumeConstants._();
|
||||
|
||||
/// Minimum volume level
|
||||
static const int min = 0;
|
||||
|
||||
/// Maximum volume level
|
||||
static const int max = 100;
|
||||
|
||||
/// Volume range for validation (0-100)
|
||||
static const int range = 100;
|
||||
}
|
||||
|
||||
/// Player state constants
|
||||
class PlayerStates {
|
||||
PlayerStates._();
|
||||
|
||||
/// Player is playing media
|
||||
static const String playing = 'playing';
|
||||
|
||||
/// Player is paused
|
||||
static const String paused = 'paused';
|
||||
|
||||
/// Player is idle/nothing loaded
|
||||
static const String idle = 'idle';
|
||||
|
||||
/// Player is off/disconnected
|
||||
static const String off = 'off';
|
||||
}
|
||||
|
||||
/// Repeat mode constants
|
||||
class RepeatModes {
|
||||
RepeatModes._();
|
||||
|
||||
/// Repeat is off
|
||||
static const String off = 'off';
|
||||
|
||||
/// Repeat all tracks
|
||||
static const String all = 'all';
|
||||
|
||||
/// Repeat single track
|
||||
static const String one = 'one';
|
||||
}
|
||||
|
||||
@@ -2996,7 +2996,8 @@ final class $$ProfilesTableReferences
|
||||
|
||||
$$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
|
||||
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
|
||||
.filter((f) => f.profileUsername.username($_item.username));
|
||||
.filter((f) => f.profileUsername.username
|
||||
.sqlEquals($_itemColumn<String>('username')!));
|
||||
|
||||
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
|
||||
return ProcessedTableManager(
|
||||
@@ -3189,7 +3190,8 @@ class $$ProfilesTableTableManager extends RootTableManager<
|
||||
getPrefetchedDataCallback: (items) async {
|
||||
return [
|
||||
if (recentlyPlayedRefs)
|
||||
await $_getPrefetchedData(
|
||||
await $_getPrefetchedData<Profile, $ProfilesTable,
|
||||
RecentlyPlayedData>(
|
||||
currentTable: table,
|
||||
referencedTable: $$ProfilesTableReferences
|
||||
._recentlyPlayedRefsTable(db),
|
||||
@@ -3254,8 +3256,10 @@ final class $$RecentlyPlayedTableReferences extends BaseReferences<
|
||||
db.recentlyPlayed.profileUsername, db.profiles.username));
|
||||
|
||||
$$ProfilesTableProcessedTableManager get profileUsername {
|
||||
final $_column = $_itemColumn<String>('profile_username')!;
|
||||
|
||||
final manager = $$ProfilesTableTableManager($_db, $_db.profiles)
|
||||
.filter((f) => f.username($_item.profileUsername));
|
||||
.filter((f) => f.username.sqlEquals($_column));
|
||||
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
|
||||
if (item == null) return manager;
|
||||
return ProcessedTableManager(
|
||||
|
||||
@@ -295,5 +295,15 @@
|
||||
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
||||
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
|
||||
"holdToSync": "Gedrückt halten zum Sync",
|
||||
"swipeToAdjustVolume": "Wischen für Lautstärke"
|
||||
"swipeToAdjustVolume": "Wischen für Lautstärke",
|
||||
|
||||
"aboutAndSupport": "Über & Unterstützung",
|
||||
"supportDevelopment": "Entwicklung unterstützen",
|
||||
"ensembleIsUnofficial": "Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.",
|
||||
"sponsorOnGitHub": "Auf GitHub sponsoren",
|
||||
"buyMeAKoFi": "Kauf mir einen Ko-fi",
|
||||
"aboutEnsemble": "Über Ensemble",
|
||||
"version": "Version",
|
||||
"visitGitHubRepo": "GitHub-Repository besuchen",
|
||||
"reportABug": "Einen Fehler melden"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"description": "Hint for port field"
|
||||
},
|
||||
|
||||
"portDescription": "Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.",
|
||||
"portDescription": "Leave blank unless using a custom port",
|
||||
"@portDescription": {
|
||||
"description": "Description for port field"
|
||||
},
|
||||
@@ -221,6 +221,36 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"discoveryMixes": "Discovery Mixes",
|
||||
"@discoveryMixes": {
|
||||
"description": "Provider discovery mixes (e.g., Tidal playlists)"
|
||||
},
|
||||
|
||||
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
|
||||
"@discoveryMixesDescription": {
|
||||
"description": "Description for discovery mixes setting"
|
||||
},
|
||||
|
||||
"discoveryRows": "Discovery rows from Music Assistant",
|
||||
"@discoveryRows": {
|
||||
"description": "Label for discovery rows section"
|
||||
},
|
||||
|
||||
"refreshDiscoveryRows": "Refresh available rows",
|
||||
"@refreshDiscoveryRows": {
|
||||
"description": "Button to refresh discovery rows"
|
||||
},
|
||||
|
||||
"noDiscoveryRows": "No discovery rows available",
|
||||
"@noDiscoveryRows": {
|
||||
"description": "Empty state when no discovery rows available"
|
||||
},
|
||||
|
||||
"discoveryRowBadge": "Music Assistant",
|
||||
"@discoveryRowBadge": {
|
||||
"description": "Badge label for discovery rows"
|
||||
},
|
||||
|
||||
"continueListening": "Continue Listening",
|
||||
"@continueListening": {
|
||||
"description": "Home screen section title"
|
||||
@@ -604,6 +634,11 @@
|
||||
|
||||
"tracksAddedToQueue": "Tracks added to queue",
|
||||
"@tracksAddedToQueue": {
|
||||
"description": "Snackbar message for tracks added to queue"
|
||||
},
|
||||
|
||||
"trackAddedToQueue": "Track added to queue",
|
||||
"@trackAddedToQueue": {
|
||||
"description": "Confirmation when tracks added"
|
||||
},
|
||||
|
||||
@@ -1069,6 +1104,10 @@
|
||||
"sortAlphabetically": "Sort alphabetically",
|
||||
"sortByYear": "Sort by year",
|
||||
"sortBySeriesOrder": "Sort by series order",
|
||||
"sortByDuration": "Sort by duration",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
"viewDetails": "View details",
|
||||
"listView": "List view",
|
||||
"gridView": "Grid view",
|
||||
"noBooksInSeries": "No books found in this series",
|
||||
@@ -1087,11 +1126,12 @@
|
||||
"showFavoritePodcasts": "Show a row of your favorite podcasts",
|
||||
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
|
||||
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
||||
"favorite": "Favorite",
|
||||
"addedToFavorites": "Added to favorites",
|
||||
"removedFromFavorites": "Removed from favorites",
|
||||
"addedToLibrary": "Added to library",
|
||||
"removedFromLibrary": "Removed from library",
|
||||
"addToLibrary": "Add to library",
|
||||
"addToLibrary": "Add",
|
||||
"unknown": "Unknown",
|
||||
"noUpcomingTracks": "No upcoming tracks",
|
||||
"showAll": "Show all",
|
||||
@@ -1207,11 +1247,21 @@
|
||||
"description": "Bottom sheet title for adding album to queue"
|
||||
},
|
||||
|
||||
"addToQueue": "Add to queue",
|
||||
"@addToQueue": {
|
||||
"description": "Add item to queue on selected player"
|
||||
},
|
||||
|
||||
"addToQueueOn": "Add to queue on...",
|
||||
"@addToQueueOn": {
|
||||
"description": "Bottom sheet title for adding to queue"
|
||||
},
|
||||
|
||||
"startRadio": "Start radio",
|
||||
"@startRadio": {
|
||||
"description": "Start radio on selected player"
|
||||
},
|
||||
|
||||
"startRadioOn": "Start {name} radio on...",
|
||||
"@startRadioOn": {
|
||||
"description": "Bottom sheet title for starting radio",
|
||||
@@ -1357,6 +1407,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"
|
||||
@@ -1653,5 +1713,60 @@
|
||||
"selectPlayerToAddToQueue": "Select player to add to queue",
|
||||
"@selectPlayerToAddToQueue": {
|
||||
"description": "Hint for selecting player to add to queue"
|
||||
},
|
||||
|
||||
"aboutAndSupport": "About & Support",
|
||||
"@aboutAndSupport": {
|
||||
"description": "About and support section title"
|
||||
},
|
||||
|
||||
"supportDevelopment": "Support Development",
|
||||
"@supportDevelopment": {
|
||||
"description": "Support development section"
|
||||
},
|
||||
|
||||
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.",
|
||||
"@ensembleIsUnofficial": {
|
||||
"description": "Description about Ensemble being unofficial"
|
||||
},
|
||||
|
||||
"sponsorOnGitHub": "Sponsor on GitHub",
|
||||
"@sponsorOnGitHub": {
|
||||
"description": "GitHub Sponsors button label"
|
||||
},
|
||||
|
||||
"buyMeAKoFi": "Buy me a Ko-fi",
|
||||
"@buyMeAKoFi": {
|
||||
"description": "Ko-fi donation button label"
|
||||
},
|
||||
|
||||
"aboutEnsemble": "About Ensemble",
|
||||
"@aboutEnsemble": {
|
||||
"description": "About Ensemble section"
|
||||
},
|
||||
|
||||
"version": "Version",
|
||||
"@version": {
|
||||
"description": "Version label"
|
||||
},
|
||||
|
||||
"visitGitHubRepo": "Visit GitHub Repository",
|
||||
"@visitGitHubRepo": {
|
||||
"description": "Link to GitHub repo"
|
||||
},
|
||||
|
||||
"reportABug": "Report a Bug",
|
||||
"@reportABug": {
|
||||
"description": "Link to report issues"
|
||||
},
|
||||
|
||||
"noRowsEnabled": "No rows enabled",
|
||||
"@noRowsEnabled": {
|
||||
"description": "Empty state when no home screen rows are enabled"
|
||||
},
|
||||
|
||||
"pullToRefreshHint": "Pull down to refresh or enable rows in settings",
|
||||
"@pullToRefreshHint": {
|
||||
"description": "Hint when home screen is empty"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,5 +491,15 @@
|
||||
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
||||
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
|
||||
"holdToSync": "Mantén presionado para sync",
|
||||
"swipeToAdjustVolume": "Desliza para ajustar volumen"
|
||||
"swipeToAdjustVolume": "Desliza para ajustar volumen",
|
||||
|
||||
"aboutAndSupport": "Acerca de y Soporte",
|
||||
"supportDevelopment": "Apoyar el Desarrollo",
|
||||
"ensembleIsUnofficial": "Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.",
|
||||
"sponsorOnGitHub": "Patrocina en GitHub",
|
||||
"buyMeAKoFi": "Cómprame un Ko-fi",
|
||||
"aboutEnsemble": "Acerca de Ensemble",
|
||||
"version": "Versión",
|
||||
"visitGitHubRepo": "Visitar Repositorio GitHub",
|
||||
"reportABug": "Reportar un Error"
|
||||
}
|
||||
|
||||
@@ -1396,5 +1396,15 @@
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"aboutAndSupport": "À propos et Support",
|
||||
"supportDevelopment": "Soutenir le Développement",
|
||||
"ensembleIsUnofficial": "Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.",
|
||||
"sponsorOnGitHub": "Sponsoriser sur GitHub",
|
||||
"buyMeAKoFi": "Offrez-moi un Ko-fi",
|
||||
"aboutEnsemble": "À propos d'Ensemble",
|
||||
"version": "Version",
|
||||
"visitGitHubRepo": "Visiter le Dépôt GitHub",
|
||||
"reportABug": "Signaler un Bug"
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ import 'app_localizations_fr.dart';
|
||||
/// be consistent with the languages listed in the S.supportedLocales
|
||||
/// property.
|
||||
abstract class S {
|
||||
S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
S(String locale)
|
||||
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
|
||||
final String localeName;
|
||||
|
||||
@@ -84,7 +85,8 @@ abstract class S {
|
||||
/// Additional delegates can be added by appending to this list in
|
||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||
/// of delegates is preferred or required.
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
@@ -162,7 +164,7 @@ abstract class S {
|
||||
/// Description for port field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.'**
|
||||
/// **'Leave blank unless using a custom port'**
|
||||
String get portDescription;
|
||||
|
||||
/// Username field label
|
||||
@@ -357,6 +359,42 @@ abstract class S {
|
||||
/// **'Discover Albums'**
|
||||
String get discoverAlbums;
|
||||
|
||||
/// Provider discovery mixes (e.g., Tidal playlists)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discovery Mixes'**
|
||||
String get discoveryMixes;
|
||||
|
||||
/// Description for discovery mixes setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
|
||||
String get discoveryMixesDescription;
|
||||
|
||||
/// Label for discovery rows section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Discovery rows from Music Assistant'**
|
||||
String get discoveryRows;
|
||||
|
||||
/// Button to refresh discovery rows
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh available rows'**
|
||||
String get refreshDiscoveryRows;
|
||||
|
||||
/// Empty state when no discovery rows available
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No discovery rows available'**
|
||||
String get noDiscoveryRows;
|
||||
|
||||
/// Badge label for discovery rows
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Music Assistant'**
|
||||
String get discoveryRowBadge;
|
||||
|
||||
/// Home screen section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -759,12 +797,18 @@ abstract class S {
|
||||
/// **'Album added to queue'**
|
||||
String get albumAddedToQueue;
|
||||
|
||||
/// Confirmation when tracks added
|
||||
/// Snackbar message for tracks added to queue
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tracks added to queue'**
|
||||
String get tracksAddedToQueue;
|
||||
|
||||
/// Confirmation when tracks added
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Track added to queue'**
|
||||
String get trackAddedToQueue;
|
||||
|
||||
/// Play button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1227,6 +1271,30 @@ abstract class S {
|
||||
/// **'Sort by series order'**
|
||||
String get sortBySeriesOrder;
|
||||
|
||||
/// No description provided for @sortByDuration.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sort by duration'**
|
||||
String get sortByDuration;
|
||||
|
||||
/// No description provided for @newestFirst.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Newest first'**
|
||||
String get newestFirst;
|
||||
|
||||
/// No description provided for @oldestFirst.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Oldest first'**
|
||||
String get oldestFirst;
|
||||
|
||||
/// No description provided for @viewDetails.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'View details'**
|
||||
String get viewDetails;
|
||||
|
||||
/// No description provided for @listView.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1335,6 +1403,12 @@ abstract class S {
|
||||
/// **'Choose which rows to display on the home screen'**
|
||||
String get chooseHomeScreenRows;
|
||||
|
||||
/// No description provided for @favorite.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favorite'**
|
||||
String get favorite;
|
||||
|
||||
/// No description provided for @addedToFavorites.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1362,7 +1436,7 @@ abstract class S {
|
||||
/// No description provided for @addToLibrary.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to library'**
|
||||
/// **'Add'**
|
||||
String get addToLibrary;
|
||||
|
||||
/// No description provided for @unknown.
|
||||
@@ -1671,12 +1745,24 @@ abstract class S {
|
||||
/// **'Add album to queue on...'**
|
||||
String get addAlbumToQueueOn;
|
||||
|
||||
/// Add item to queue on selected player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to queue'**
|
||||
String get addToQueue;
|
||||
|
||||
/// Bottom sheet title for adding to queue
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to queue on...'**
|
||||
String get addToQueueOn;
|
||||
|
||||
/// Start radio on selected player
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start radio'**
|
||||
String get startRadio;
|
||||
|
||||
/// Bottom sheet title for starting radio
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1833,6 +1919,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:
|
||||
@@ -2120,6 +2218,72 @@ abstract class S {
|
||||
/// In en, this message translates to:
|
||||
/// **'Select player to add to queue'**
|
||||
String get selectPlayerToAddToQueue;
|
||||
|
||||
/// About and support section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About & Support'**
|
||||
String get aboutAndSupport;
|
||||
|
||||
/// Support development section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Support Development'**
|
||||
String get supportDevelopment;
|
||||
|
||||
/// Description about Ensemble being unofficial
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'**
|
||||
String get ensembleIsUnofficial;
|
||||
|
||||
/// GitHub Sponsors button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sponsor on GitHub'**
|
||||
String get sponsorOnGitHub;
|
||||
|
||||
/// Ko-fi donation button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Buy me a Ko-fi'**
|
||||
String get buyMeAKoFi;
|
||||
|
||||
/// About Ensemble section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About Ensemble'**
|
||||
String get aboutEnsemble;
|
||||
|
||||
/// Version label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Version'**
|
||||
String get version;
|
||||
|
||||
/// Link to GitHub repo
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Visit GitHub Repository'**
|
||||
String get visitGitHubRepo;
|
||||
|
||||
/// Link to report issues
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report a Bug'**
|
||||
String get reportABug;
|
||||
|
||||
/// Empty state when no home screen rows are enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No rows enabled'**
|
||||
String get noRowsEnabled;
|
||||
|
||||
/// Hint when home screen is empty
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pull down to refresh or enable rows in settings'**
|
||||
String get pullToRefreshHint;
|
||||
}
|
||||
|
||||
class _SDelegate extends LocalizationsDelegate<S> {
|
||||
@@ -2131,27 +2295,29 @@ class _SDelegate extends LocalizationsDelegate<S> {
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => <String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_SDelegate old) => false;
|
||||
}
|
||||
|
||||
S lookupS(Locale locale) {
|
||||
|
||||
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'de': return SDe();
|
||||
case 'en': return SEn();
|
||||
case 'es': return SEs();
|
||||
case 'fr': return SFr();
|
||||
case 'de':
|
||||
return SDe();
|
||||
case 'en':
|
||||
return SEn();
|
||||
case 'es':
|
||||
return SEs();
|
||||
case 'fr':
|
||||
return SFr();
|
||||
}
|
||||
|
||||
throw FlutterError(
|
||||
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.'
|
||||
);
|
||||
'S.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SDe extends S {
|
||||
String get portHint => 'z.B. 8095 oder leer lassen';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||
String get portDescription =>
|
||||
'Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.';
|
||||
|
||||
@override
|
||||
String get username => 'Benutzername';
|
||||
@@ -60,10 +61,12 @@ class SDe extends S {
|
||||
String get authServerUrlOptional => 'Auth-Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||
String get authServerUrlHint =>
|
||||
'z.B. auth.example.com (wenn abweichend vom Server)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||
String get authServerUrlDescription =>
|
||||
'Leer lassen, wenn Authentifizierung auf demselben Server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +83,49 @@ class SDe extends S {
|
||||
String get unencryptedConnection => 'Unverschlusselte Verbindung';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'HTTP uber Tailscale (verschlusselter Tunnel)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS fehlgeschlagen, verwende HTTP';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||
String get httpNotEncrypted =>
|
||||
'HTTP-Verbindung - Daten sind nicht verschlusselt';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Bitte gib deine Music Assistant Serveradresse ein';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Bitte gib deine Music Assistant Serveradresse ein';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Bitte gib deinen Namen ein';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Bitte gib eine gultige Portnummer ein (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Bitte gib Benutzername und Passwort ein';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Bitte gib Benutzername und Passwort ein';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
String get authFailed =>
|
||||
'Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
String get maLoginFailed =>
|
||||
'Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
|
||||
String get connectionFailed =>
|
||||
'Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Erkenne Authentifizierung...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Keine Authentifizierung';
|
||||
@@ -139,6 +151,25 @@ class SDe extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Alben entdecken';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Weiterhoren';
|
||||
|
||||
@@ -221,10 +252,12 @@ class SDe extends S {
|
||||
String get noRadioStations => 'Keine Radiosender';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Füge Radiosender in Music Assistant hinzu';
|
||||
String get addRadioStationsHint =>
|
||||
'Füge Radiosender in Music Assistant hinzu';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||
String get searchFailed =>
|
||||
'Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.';
|
||||
|
||||
@override
|
||||
String get queue => 'Warteschlange';
|
||||
@@ -359,6 +392,9 @@ class SDe extends S {
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Songs zur Warteschlange hinzugefügt';
|
||||
|
||||
@override
|
||||
String get trackAddedToQueue => 'Track added to queue';
|
||||
|
||||
@override
|
||||
String get play => 'Abspielen';
|
||||
|
||||
@@ -471,37 +507,43 @@ class SDe extends S {
|
||||
String get noFavoriteAudiobooks => 'Keine Lieblingshörbücher';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
|
||||
String get tapHeartAudiobook =>
|
||||
'Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'Keine Hörbücher';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
|
||||
String get addAudiobooksHint =>
|
||||
'Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'Keine Lieblingskünstler';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
|
||||
String get tapHeartArtist =>
|
||||
'Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'Keine Lieblingsalben';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
|
||||
String get tapHeartAlbum =>
|
||||
'Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'Keine Lieblings-Wiedergabelisten';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
|
||||
String get tapHeartPlaylist =>
|
||||
'Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'Keine Lieblingssongs';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
|
||||
String get longPressTrackHint =>
|
||||
'Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Serien laden';
|
||||
@@ -513,7 +555,8 @@ class SDe extends S {
|
||||
String get notConnectedTitle => 'Nicht verbunden';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||
String get connectHint =>
|
||||
'Verbinde dich mit deinem Music Assistant Server, um Musik zu hören';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Server konfigurieren';
|
||||
@@ -549,7 +592,8 @@ class SDe extends S {
|
||||
String get noLogsToCopy => 'Keine Protokolle zum Kopieren';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||
String get noDebugLogsYet =>
|
||||
'Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Debug anzeigen';
|
||||
@@ -624,6 +668,18 @@ class SDe extends S {
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Nach Serienreihenfolge sortieren';
|
||||
|
||||
@override
|
||||
String get sortByDuration => 'Sort by duration';
|
||||
|
||||
@override
|
||||
String get newestFirst => 'Newest first';
|
||||
|
||||
@override
|
||||
String get oldestFirst => 'Oldest first';
|
||||
|
||||
@override
|
||||
String get viewDetails => 'View details';
|
||||
|
||||
@override
|
||||
String get listView => 'Listenansicht';
|
||||
|
||||
@@ -649,10 +705,12 @@ class SDe extends S {
|
||||
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||
String get showRandomAudiobooks =>
|
||||
'Zufällige Hörbücher zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||
String get showRandomSeries =>
|
||||
'Zufällige Hörbuchserien zum Entdecken anzeigen';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Lieblingsalben anzeigen';
|
||||
@@ -667,16 +725,22 @@ class SDe extends S {
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Farben aus Album- und Künstlercover extrahieren';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Farben aus Album- und Künstlercover extrahieren';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
|
||||
@@ -691,7 +755,7 @@ class SDe extends S {
|
||||
String get removedFromLibrary => 'Removed from library';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Add to library';
|
||||
String get addToLibrary => 'Add';
|
||||
|
||||
@override
|
||||
String get unknown => 'Unbekannt';
|
||||
@@ -785,13 +849,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 +866,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 +887,19 @@ class SDe extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
|
||||
String get noSeriesAvailable =>
|
||||
'Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
|
||||
String get pullToLoadSeries =>
|
||||
'Ziehe nach unten um Serien\nvon Music Assistant zu laden';
|
||||
|
||||
@override
|
||||
String get search => 'Suche';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
|
||||
String get metadataApisDescription =>
|
||||
'Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Last.fm API-Schlüssel';
|
||||
@@ -854,9 +924,15 @@ class SDe extends S {
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Album zur Warteschlange hinzufügen auf...';
|
||||
|
||||
@override
|
||||
String get addToQueue => 'Add to queue';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Zur Warteschlange hinzufügen auf...';
|
||||
|
||||
@override
|
||||
String get startRadio => 'Start radio';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return '$name Radio starten auf...';
|
||||
@@ -901,19 +977,22 @@ class SDe extends S {
|
||||
String get preferLocalPlayer => 'Lokales Gerät bevorzugen';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Dieses Gerät immer zuerst auswählen, wenn verfügbar';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Intelligente Sortierung';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Nicht verfügbar';
|
||||
@@ -945,7 +1024,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 +1041,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 +1060,15 @@ class SDe extends S {
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
String get welcomeMessage =>
|
||||
'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -994,19 +1084,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) {
|
||||
@@ -1112,4 +1205,39 @@ class SDe extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'Über & Unterstützung';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Entwicklung unterstützen';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Auf GitHub sponsoren';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Kauf mir einen Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'Über Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'GitHub-Repository besuchen';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Einen Fehler melden';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,7 @@ class SEn extends S {
|
||||
String get portHint => 'e.g., 8095 or leave blank';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.';
|
||||
String get portDescription => 'Leave blank unless using a custom port';
|
||||
|
||||
@override
|
||||
String get username => 'Username';
|
||||
@@ -60,10 +60,12 @@ class SEn extends S {
|
||||
String get authServerUrlOptional => 'Auth Server URL (Optional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'e.g., auth.example.com (if different from server)';
|
||||
String get authServerUrlHint =>
|
||||
'e.g., auth.example.com (if different from server)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Leave empty if authentication is on the same server';
|
||||
String get authServerUrlDescription =>
|
||||
'Leave empty if authentication is on the same server';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,7 +82,8 @@ class SEn extends S {
|
||||
String get unencryptedConnection => 'Unencrypted Connection';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Using HTTP over Tailscale (encrypted tunnel)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Using HTTP over Tailscale (encrypted tunnel)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS failed, using HTTP fallback';
|
||||
@@ -89,31 +92,37 @@ class SEn extends S {
|
||||
String get httpNotEncrypted => 'HTTP connection - data is not encrypted';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Please enter your Music Assistant server address';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Please enter your Music Assistant server address';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Please enter your name';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Please enter a valid port number (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Please enter a valid port number (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Please enter username and password';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Authentication failed. Please check your credentials.';
|
||||
String get authFailed =>
|
||||
'Authentication failed. Please check your credentials.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Music Assistant login failed. Please check your credentials.';
|
||||
String get maLoginFailed =>
|
||||
'Music Assistant login failed. Please check your credentials.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Could not connect to server. Please check the address and try again.';
|
||||
String get connectionFailed =>
|
||||
'Could not connect to server. Please check the address and try again.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detecting authentication...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Cannot determine authentication requirements. Please check server URL.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Cannot determine authentication requirements. Please check server URL.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'No Authentication';
|
||||
@@ -139,6 +148,25 @@ class SEn extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Discover Albums';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continue Listening';
|
||||
|
||||
@@ -359,6 +387,9 @@ class SEn extends S {
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Tracks added to queue';
|
||||
|
||||
@override
|
||||
String get trackAddedToQueue => 'Track added to queue';
|
||||
|
||||
@override
|
||||
String get play => 'Play';
|
||||
|
||||
@@ -471,37 +502,43 @@ class SEn extends S {
|
||||
String get noFavoriteAudiobooks => 'No favorite audiobooks';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Tap the heart on an audiobook to add it to favorites';
|
||||
String get tapHeartAudiobook =>
|
||||
'Tap the heart on an audiobook to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'No audiobooks';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Add audiobooks to your library to see them here';
|
||||
String get addAudiobooksHint =>
|
||||
'Add audiobooks to your library to see them here';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'No favorite artists';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Tap the heart on an artist to add them to favorites';
|
||||
String get tapHeartArtist =>
|
||||
'Tap the heart on an artist to add them to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'No favorite albums';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Tap the heart on an album to add it to favorites';
|
||||
String get tapHeartAlbum =>
|
||||
'Tap the heart on an album to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'No favorite playlists';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Tap the heart on a playlist to add it to favorites';
|
||||
String get tapHeartPlaylist =>
|
||||
'Tap the heart on a playlist to add it to favorites';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'No favorite tracks';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Long-press a track and tap the heart to add it to favorites';
|
||||
String get longPressTrackHint =>
|
||||
'Long-press a track and tap the heart to add it to favorites';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Load Series';
|
||||
@@ -513,7 +550,8 @@ class SEn extends S {
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connect to your Music Assistant server to start listening';
|
||||
String get connectHint =>
|
||||
'Connect to your Music Assistant server to start listening';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configure Server';
|
||||
@@ -624,6 +662,18 @@ class SEn extends S {
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Sort by series order';
|
||||
|
||||
@override
|
||||
String get sortByDuration => 'Sort by duration';
|
||||
|
||||
@override
|
||||
String get newestFirst => 'Newest first';
|
||||
|
||||
@override
|
||||
String get oldestFirst => 'Oldest first';
|
||||
|
||||
@override
|
||||
String get viewDetails => 'View details';
|
||||
|
||||
@override
|
||||
String get listView => 'List view';
|
||||
|
||||
@@ -667,16 +717,22 @@ class SEn extends S {
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extract colors from album and artist artwork';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extract colors from album and artist artwork';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Choose which rows to display on the home screen';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choose which rows to display on the home screen';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Added to favorites';
|
||||
@@ -691,7 +747,7 @@ class SEn extends S {
|
||||
String get removedFromLibrary => 'Removed from library';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Add to library';
|
||||
String get addToLibrary => 'Add';
|
||||
|
||||
@override
|
||||
String get unknown => 'Unknown';
|
||||
@@ -791,7 +847,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 +857,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 +878,19 @@ class SEn extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No series available from your audiobook library.\nPull to refresh.';
|
||||
String get noSeriesAvailable =>
|
||||
'No series available from your audiobook library.\nPull to refresh.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Pull down to load series\nfrom Music Assistant';
|
||||
String get pullToLoadSeries =>
|
||||
'Pull down to load series\nfrom Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Search';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
|
||||
String get metadataApisDescription =>
|
||||
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Last.fm API Key';
|
||||
@@ -854,9 +915,15 @@ class SEn extends S {
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Add album to queue on...';
|
||||
|
||||
@override
|
||||
String get addToQueue => 'Add to queue';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Add to queue on...';
|
||||
|
||||
@override
|
||||
String get startRadio => 'Start radio';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Start $name radio on...';
|
||||
@@ -901,19 +968,22 @@ class SEn extends S {
|
||||
String get preferLocalPlayer => 'Prefer Local Player';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Always select this device first when available';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Always select this device first when available';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Smart Sort';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Sort by status (playing, on, off) instead of alphabetically';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Unavailable';
|
||||
@@ -945,7 +1015,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 +1032,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 +1051,15 @@ class SEn extends S {
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
String get welcomeMessage =>
|
||||
'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -994,19 +1075,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) {
|
||||
@@ -1112,4 +1196,39 @@ class SEn extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'About & Support';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Support Development';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Sponsor on GitHub';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Buy me a Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'About Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'Visit GitHub Repository';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Report a Bug';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SEs extends S {
|
||||
String get portHint => 'ej., 8095 o dejar en blanco';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
|
||||
String get portDescription =>
|
||||
'Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.';
|
||||
|
||||
@override
|
||||
String get username => 'Usuario';
|
||||
@@ -57,13 +58,16 @@ class SEs extends S {
|
||||
String get disconnect => 'Desconectar';
|
||||
|
||||
@override
|
||||
String get authServerUrlOptional => 'URL del servidor de autenticación (Opcional)';
|
||||
String get authServerUrlOptional =>
|
||||
'URL del servidor de autenticación (Opcional)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ej., auth.example.com (si es diferente del servidor)';
|
||||
String get authServerUrlHint =>
|
||||
'ej., auth.example.com (si es diferente del servidor)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Dejar vacío si la autenticación está en el mismo servidor';
|
||||
String get authServerUrlDescription =>
|
||||
'Dejar vacío si la autenticación está en el mismo servidor';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +84,49 @@ class SEs extends S {
|
||||
String get unencryptedConnection => 'Conexión sin cifrar';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Usando HTTP sobre Tailscale (túnel cifrado)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS falló, usando HTTP como alternativa';
|
||||
String get httpsFailedUsingHttp =>
|
||||
'HTTPS falló, usando HTTP como alternativa';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'Conexión HTTP - los datos no están cifrados';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Por favor, introduce la dirección de tu servidor Music Assistant';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Por favor, introduce la dirección de tu servidor Music Assistant';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Por favor, introduce tu nombre';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Por favor, introduce un número de puerto válido (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Por favor, introduce un número de puerto válido (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Por favor, introduce usuario y contraseña';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Por favor, introduce usuario y contraseña';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||
String get authFailed =>
|
||||
'Autenticación fallida. Por favor, verifica tus credenciales.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
|
||||
String get maLoginFailed =>
|
||||
'Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
|
||||
String get connectionFailed =>
|
||||
'No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detectando autenticación...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
|
||||
String get cannotDetermineAuth =>
|
||||
'No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Sin autenticación';
|
||||
@@ -139,6 +152,25 @@ class SEs extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Descubrir álbumes';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuar escuchando';
|
||||
|
||||
@@ -221,10 +253,12 @@ class SEs extends S {
|
||||
String get noRadioStations => 'No hay emisoras de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Añade emisoras de radio en Music Assistant';
|
||||
String get addRadioStationsHint =>
|
||||
'Añade emisoras de radio en Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||
String get searchFailed =>
|
||||
'Búsqueda fallida. Por favor, verifica tu conexión.';
|
||||
|
||||
@override
|
||||
String get queue => 'Cola';
|
||||
@@ -264,7 +298,8 @@ class SEs extends S {
|
||||
String get system => 'Sistema';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||
String get pullToRefresh =>
|
||||
'Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Ver registros de depuración';
|
||||
@@ -301,7 +336,8 @@ class SEs extends S {
|
||||
String get logsCleared => 'Registros borrados';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Lista de reproductores copiada al portapapeles';
|
||||
String get playerListCopied =>
|
||||
'Lista de reproductores copiada al portapapeles';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Aún no hay registros';
|
||||
@@ -359,6 +395,9 @@ class SEs extends S {
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Canciones añadidas a la cola';
|
||||
|
||||
@override
|
||||
String get trackAddedToQueue => 'Track added to queue';
|
||||
|
||||
@override
|
||||
String get play => 'Reproducir';
|
||||
|
||||
@@ -471,37 +510,43 @@ class SEs extends S {
|
||||
String get noFavoriteAudiobooks => 'No hay audiolibros favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Toca el corazón en un audiolibro para añadirlo a favoritos';
|
||||
String get tapHeartAudiobook =>
|
||||
'Toca el corazón en un audiolibro para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'No hay audiolibros';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Añade audiolibros a tu biblioteca para verlos aquí';
|
||||
String get addAudiobooksHint =>
|
||||
'Añade audiolibros a tu biblioteca para verlos aquí';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'No hay artistas favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Toca el corazón en un artista para añadirlo a favoritos';
|
||||
String get tapHeartArtist =>
|
||||
'Toca el corazón en un artista para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'No hay álbumes favoritos';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Toca el corazón en un álbum para añadirlo a favoritos';
|
||||
String get tapHeartAlbum =>
|
||||
'Toca el corazón en un álbum para añadirlo a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'No hay listas de reproducción favoritas';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Toca el corazón en una lista para añadirla a favoritos';
|
||||
String get tapHeartPlaylist =>
|
||||
'Toca el corazón en una lista para añadirla a favoritos';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'No hay canciones favoritas';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
|
||||
String get longPressTrackHint =>
|
||||
'Mantén presionada una canción y toca el corazón para añadirla a favoritos';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Cargar series';
|
||||
@@ -513,7 +558,8 @@ class SEs extends S {
|
||||
String get notConnectedTitle => 'No conectado';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||
String get connectHint =>
|
||||
'Conéctate a tu servidor Music Assistant para empezar a escuchar';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurar servidor';
|
||||
@@ -549,7 +595,8 @@ class SEs extends S {
|
||||
String get noLogsToCopy => 'No hay registros para copiar';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||
String get noDebugLogsYet =>
|
||||
'Aún no hay registros de depuración. Intenta detectar autenticación.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Mostrar depuración';
|
||||
@@ -564,7 +611,8 @@ class SEs extends S {
|
||||
String get noChapters => 'Sin capítulos';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Este audiolibro no tiene información de capítulos';
|
||||
String get noChapterInfo =>
|
||||
'Este audiolibro no tiene información de capítulos';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
@@ -624,6 +672,18 @@ class SEs extends S {
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Ordenar por orden de serie';
|
||||
|
||||
@override
|
||||
String get sortByDuration => 'Sort by duration';
|
||||
|
||||
@override
|
||||
String get newestFirst => 'Newest first';
|
||||
|
||||
@override
|
||||
String get oldestFirst => 'Oldest first';
|
||||
|
||||
@override
|
||||
String get viewDetails => 'View details';
|
||||
|
||||
@override
|
||||
String get listView => 'Vista de lista';
|
||||
|
||||
@@ -637,7 +697,8 @@ class SEs extends S {
|
||||
String get artist => 'Artista';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Mostrar álbumes reproducidos recientemente';
|
||||
String get showRecentlyPlayedAlbums =>
|
||||
'Mostrar álbumes reproducidos recientemente';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Mostrar artistas aleatorios para descubrir';
|
||||
@@ -649,34 +710,44 @@ class SEs extends S {
|
||||
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Mostrar audiolibros aleatorios para descubrir';
|
||||
String get showRandomAudiobooks =>
|
||||
'Mostrar audiolibros aleatorios para descubrir';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Mostrar series de audiolibros aleatorias para descubrir';
|
||||
String get showRandomSeries =>
|
||||
'Mostrar series de audiolibros aleatorias para descubrir';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Mostrar una fila de tus álbumes favoritos';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Mostrar una fila de tus artistas favoritos';
|
||||
String get showFavoriteArtists =>
|
||||
'Mostrar una fila de tus artistas favoritos';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Mostrar una fila de tus canciones favoritas';
|
||||
String get showFavoriteTracks =>
|
||||
'Mostrar una fila de tus canciones favoritas';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Show a row of your favorite playlists';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Show a row of your favorite radio stations';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Show a row of your favorite radio stations';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Show a row of your favorite podcasts';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extraer colores de las portadas de álbumes y artistas';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extraer colores de las portadas de álbumes y artistas';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Elige qué filas mostrar en la pantalla de inicio';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Elige qué filas mostrar en la pantalla de inicio';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Añadido a favoritos';
|
||||
@@ -691,7 +762,7 @@ class SEs extends S {
|
||||
String get removedFromLibrary => 'Removed from library';
|
||||
|
||||
@override
|
||||
String get addToLibrary => 'Add to library';
|
||||
String get addToLibrary => 'Add';
|
||||
|
||||
@override
|
||||
String get unknown => 'Desconocido';
|
||||
@@ -774,7 +845,8 @@ class SEs extends S {
|
||||
String get loadingChapters => 'Cargando capítulos...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'No hay información de capítulos disponible';
|
||||
String get noChapterInfoAvailable =>
|
||||
'No hay información de capítulos disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
@@ -785,13 +857,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 +874,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 +895,19 @@ class SEs extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
|
||||
String get noSeriesAvailable =>
|
||||
'No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Desliza hacia abajo para cargar series\ndesde Music Assistant';
|
||||
String get pullToLoadSeries =>
|
||||
'Desliza hacia abajo para cargar series\ndesde Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Buscar';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
|
||||
String get metadataApisDescription =>
|
||||
'Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Clave API de Last.fm';
|
||||
@@ -854,9 +932,15 @@ class SEs extends S {
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Añadir álbum a la cola en...';
|
||||
|
||||
@override
|
||||
String get addToQueue => 'Add to queue';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Añadir a la cola en...';
|
||||
|
||||
@override
|
||||
String get startRadio => 'Start radio';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Iniciar radio de $name en...';
|
||||
@@ -901,19 +985,22 @@ class SEs extends S {
|
||||
String get preferLocalPlayer => 'Preferir dispositivo local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Seleccionar siempre este dispositivo primero cuando esté disponible';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Orden inteligente';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'No disponible';
|
||||
@@ -945,7 +1032,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 +1049,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 +1068,15 @@ class SEs extends S {
|
||||
String get welcomeToEnsemble => 'Welcome to Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
String get welcomeMessage =>
|
||||
'By default your phone is the selected player.\nPull the mini player down to select another player.';
|
||||
|
||||
@override
|
||||
String get skip => 'Skip';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Swipe down, tap outside, or press back to return';
|
||||
String get dismissPlayerHint =>
|
||||
'Swipe down, tap outside, or press back to return';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -994,19 +1092,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) {
|
||||
@@ -1112,4 +1213,39 @@ class SEs extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'Acerca de y Soporte';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Apoyar el Desarrollo';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Patrocina en GitHub';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Cómprame un Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'Acerca de Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Versión';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'Visitar Repositorio GitHub';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Reportar un Error';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ignore: unused_import
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -39,7 +39,8 @@ class SFr extends S {
|
||||
String get portHint => 'ex., 8095 ou laisser vide';
|
||||
|
||||
@override
|
||||
String get portDescription => 'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
|
||||
String get portDescription =>
|
||||
'Laisser vide pour les proxys inverses ou les ports standards. Entrer 8095 pour une connexion directe.';
|
||||
|
||||
@override
|
||||
String get username => 'Nom d\'utilisateur';
|
||||
@@ -60,10 +61,12 @@ class SFr extends S {
|
||||
String get authServerUrlOptional => 'URL du serveur d\'auth (Facultatif)';
|
||||
|
||||
@override
|
||||
String get authServerUrlHint => 'ex., auth.example.com (si different du serveur)';
|
||||
String get authServerUrlHint =>
|
||||
'ex., auth.example.com (si different du serveur)';
|
||||
|
||||
@override
|
||||
String get authServerUrlDescription => 'Laisser vide si l\'authentification est sur le meme serveur';
|
||||
String get authServerUrlDescription =>
|
||||
'Laisser vide si l\'authentification est sur le meme serveur';
|
||||
|
||||
@override
|
||||
String detectedAuthType(String authType) {
|
||||
@@ -80,40 +83,50 @@ class SFr extends S {
|
||||
String get unencryptedConnection => 'Connexion non chiffrée';
|
||||
|
||||
@override
|
||||
String get usingHttpOverTailscale => 'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||
String get usingHttpOverTailscale =>
|
||||
'Utilisation de HTTP via Tailscale (tunnel chiffre)';
|
||||
|
||||
@override
|
||||
String get httpsFailedUsingHttp => 'HTTPS a échoué, utilisation de HTTP en repli';
|
||||
String get httpsFailedUsingHttp =>
|
||||
'HTTPS a échoué, utilisation de HTTP en repli';
|
||||
|
||||
@override
|
||||
String get httpNotEncrypted => 'Connexion HTTP - les donnees ne sont pas chiffrées';
|
||||
String get httpNotEncrypted =>
|
||||
'Connexion HTTP - les donnees ne sont pas chiffrées';
|
||||
|
||||
@override
|
||||
String get pleaseEnterServerAddress => 'Veuillez entrer l\'adresse de votre serveur Music Assistant';
|
||||
String get pleaseEnterServerAddress =>
|
||||
'Veuillez entrer l\'adresse de votre serveur Music Assistant';
|
||||
|
||||
@override
|
||||
String get pleaseEnterName => 'Veuillez entrer votre nom';
|
||||
|
||||
@override
|
||||
String get pleaseEnterValidPort => 'Veuillez entrer un numero de port valide (1-65535)';
|
||||
String get pleaseEnterValidPort =>
|
||||
'Veuillez entrer un numero de port valide (1-65535)';
|
||||
|
||||
@override
|
||||
String get pleaseEnterCredentials => 'Veuillez entrer le nom d\'utilisateur et le mot de passe';
|
||||
String get pleaseEnterCredentials =>
|
||||
'Veuillez entrer le nom d\'utilisateur et le mot de passe';
|
||||
|
||||
@override
|
||||
String get authFailed => 'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
||||
String get authFailed =>
|
||||
'Echec de l\'authentification. Veuillez verifier vos identifiants.';
|
||||
|
||||
@override
|
||||
String get maLoginFailed => 'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
|
||||
String get maLoginFailed =>
|
||||
'Echec de la connexion a Music Assistant. Veuillez verifier vos identifiants.';
|
||||
|
||||
@override
|
||||
String get connectionFailed => 'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
|
||||
String get connectionFailed =>
|
||||
'Impossible de se connecter au serveur. Veuillez verifier l\'adresse et reessayer.';
|
||||
|
||||
@override
|
||||
String get detectingAuth => 'Detection de l\'authentification...';
|
||||
|
||||
@override
|
||||
String get cannotDetermineAuth => 'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
|
||||
String get cannotDetermineAuth =>
|
||||
'Impossible de determiner les exigences d\'authentification. Veuillez verifier l\'URL du serveur.';
|
||||
|
||||
@override
|
||||
String get noAuthentication => 'Pas d\'authentification';
|
||||
@@ -128,7 +141,8 @@ class SFr extends S {
|
||||
String get musicAssistantLogin => 'Connexion Music Assistant';
|
||||
|
||||
@override
|
||||
String get pressBackToMinimize => 'Appuyez a nouveau sur retour pour minimiser';
|
||||
String get pressBackToMinimize =>
|
||||
'Appuyez a nouveau sur retour pour minimiser';
|
||||
|
||||
@override
|
||||
String get recentlyPlayed => 'Ecoutes recemment';
|
||||
@@ -139,6 +153,25 @@ class SFr extends S {
|
||||
@override
|
||||
String get discoverAlbums => 'Découvrir des albums';
|
||||
|
||||
@override
|
||||
String get discoveryMixes => 'Discovery Mixes';
|
||||
|
||||
@override
|
||||
String get discoveryMixesDescription =>
|
||||
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
|
||||
|
||||
@override
|
||||
String get discoveryRows => 'Discovery rows from Music Assistant';
|
||||
|
||||
@override
|
||||
String get refreshDiscoveryRows => 'Refresh available rows';
|
||||
|
||||
@override
|
||||
String get noDiscoveryRows => 'No discovery rows available';
|
||||
|
||||
@override
|
||||
String get discoveryRowBadge => 'Music Assistant';
|
||||
|
||||
@override
|
||||
String get continueListening => 'Continuer l\'écoute';
|
||||
|
||||
@@ -221,10 +254,12 @@ class SFr extends S {
|
||||
String get noRadioStations => 'Aucune station de radio';
|
||||
|
||||
@override
|
||||
String get addRadioStationsHint => 'Ajouter des stations de radio dans Music Assistant';
|
||||
String get addRadioStationsHint =>
|
||||
'Ajouter des stations de radio dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get searchFailed => 'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||
String get searchFailed =>
|
||||
'La recherche a échoué. Veuillez verifier votre connexion.';
|
||||
|
||||
@override
|
||||
String get queue => 'File d\'attente';
|
||||
@@ -264,7 +299,8 @@ class SFr extends S {
|
||||
String get system => 'Système';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||
String get pullToRefresh =>
|
||||
'Tirez pour rafraichir la bibliothèque et appliquer les modifications';
|
||||
|
||||
@override
|
||||
String get viewDebugLogs => 'Voir les journaux de debogage';
|
||||
@@ -301,7 +337,8 @@ class SFr extends S {
|
||||
String get logsCleared => 'Journaux effaces';
|
||||
|
||||
@override
|
||||
String get playerListCopied => 'Liste des lecteurs copiee dans le presse-papiers !';
|
||||
String get playerListCopied =>
|
||||
'Liste des lecteurs copiee dans le presse-papiers !';
|
||||
|
||||
@override
|
||||
String get noLogsYet => 'Pas encore de journaux';
|
||||
@@ -359,6 +396,9 @@ class SFr extends S {
|
||||
@override
|
||||
String get tracksAddedToQueue => 'Pistes ajoutées a la file d\'attente';
|
||||
|
||||
@override
|
||||
String get trackAddedToQueue => 'Track added to queue';
|
||||
|
||||
@override
|
||||
String get play => 'Lire';
|
||||
|
||||
@@ -471,37 +511,43 @@ class SFr extends S {
|
||||
String get noFavoriteAudiobooks => 'Aucun livre audio favori';
|
||||
|
||||
@override
|
||||
String get tapHeartAudiobook => 'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
|
||||
String get tapHeartAudiobook =>
|
||||
'Appuyez sur le coeur d\'un livre audio pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noAudiobooks => 'Aucun livre audio';
|
||||
|
||||
@override
|
||||
String get addAudiobooksHint => 'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
|
||||
String get addAudiobooksHint =>
|
||||
'Ajoutez des livres audio a votre bibliothèque pour les voir ici';
|
||||
|
||||
@override
|
||||
String get noFavoriteArtists => 'Aucun artiste favori';
|
||||
|
||||
@override
|
||||
String get tapHeartArtist => 'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
|
||||
String get tapHeartArtist =>
|
||||
'Appuyez sur le coeur d\'un artiste pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoriteAlbums => 'Aucun album favori';
|
||||
|
||||
@override
|
||||
String get tapHeartAlbum => 'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
|
||||
String get tapHeartAlbum =>
|
||||
'Appuyez sur le coeur d\'un album pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoritePlaylists => 'Aucune playlist favorite';
|
||||
|
||||
@override
|
||||
String get tapHeartPlaylist => 'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
|
||||
String get tapHeartPlaylist =>
|
||||
'Appuyez sur le coeur d\'une playlist pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get noFavoriteTracks => 'Aucune piste favorite';
|
||||
|
||||
@override
|
||||
String get longPressTrackHint => 'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
|
||||
String get longPressTrackHint =>
|
||||
'Appuyez longuement sur une piste et touchez le coeur pour l\'ajoutér aux favoris';
|
||||
|
||||
@override
|
||||
String get loadSeries => 'Charger les séries';
|
||||
@@ -513,7 +559,8 @@ class SFr extends S {
|
||||
String get notConnectedTitle => 'Not Connected';
|
||||
|
||||
@override
|
||||
String get connectHint => 'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||
String get connectHint =>
|
||||
'Connectéz-vous a votre serveur Music Assistant pour commencer a écouter';
|
||||
|
||||
@override
|
||||
String get configureServer => 'Configurer le serveur';
|
||||
@@ -549,7 +596,8 @@ class SFr extends S {
|
||||
String get noLogsToCopy => 'Aucun journal a copier';
|
||||
|
||||
@override
|
||||
String get noDebugLogsYet => 'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||
String get noDebugLogsYet =>
|
||||
'Pas encore de journaux de debogage. Essayez de détectér l\'authentification.';
|
||||
|
||||
@override
|
||||
String get showDebug => 'Afficher le debogage';
|
||||
@@ -564,7 +612,8 @@ class SFr extends S {
|
||||
String get noChapters => 'Aucun chapitre';
|
||||
|
||||
@override
|
||||
String get noChapterInfo => 'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||
String get noChapterInfo =>
|
||||
'Ce livre audio n\'a pas d\'informations de chapitre';
|
||||
|
||||
@override
|
||||
String errorSeeking(String error) {
|
||||
@@ -624,6 +673,18 @@ class SFr extends S {
|
||||
@override
|
||||
String get sortBySeriesOrder => 'Trier par ordre de série';
|
||||
|
||||
@override
|
||||
String get sortByDuration => 'Sort by duration';
|
||||
|
||||
@override
|
||||
String get newestFirst => 'Newest first';
|
||||
|
||||
@override
|
||||
String get oldestFirst => 'Oldest first';
|
||||
|
||||
@override
|
||||
String get viewDetails => 'View details';
|
||||
|
||||
@override
|
||||
String get listView => 'Vue en liste';
|
||||
|
||||
@@ -637,10 +698,12 @@ class SFr extends S {
|
||||
String get artist => 'Artiste';
|
||||
|
||||
@override
|
||||
String get showRecentlyPlayedAlbums => 'Afficher les albums écoutes recemment';
|
||||
String get showRecentlyPlayedAlbums =>
|
||||
'Afficher les albums écoutes recemment';
|
||||
|
||||
@override
|
||||
String get showRandomArtists => 'Afficher des artistes aléatoires a découvrir';
|
||||
String get showRandomArtists =>
|
||||
'Afficher des artistes aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
|
||||
@@ -649,34 +712,46 @@ class SFr extends S {
|
||||
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
|
||||
|
||||
@override
|
||||
String get showRandomAudiobooks => 'Afficher des livres audio aléatoires a découvrir';
|
||||
String get showRandomAudiobooks =>
|
||||
'Afficher des livres audio aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showRandomSeries => 'Afficher des séries de livres audio aléatoires a découvrir';
|
||||
String get showRandomSeries =>
|
||||
'Afficher des séries de livres audio aléatoires a découvrir';
|
||||
|
||||
@override
|
||||
String get showFavoriteAlbums => 'Afficher une rangee de vos albums favoris';
|
||||
|
||||
@override
|
||||
String get showFavoriteArtists => 'Afficher une rangee de vos artistes favoris';
|
||||
String get showFavoriteArtists =>
|
||||
'Afficher une rangee de vos artistes favoris';
|
||||
|
||||
@override
|
||||
String get showFavoriteTracks => 'Afficher une rangee de vos pistes favorites';
|
||||
String get showFavoriteTracks =>
|
||||
'Afficher une rangee de vos pistes favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePlaylists => 'Afficher une rangee de vos playlists favorites';
|
||||
String get showFavoritePlaylists =>
|
||||
'Afficher une rangee de vos playlists favorites';
|
||||
|
||||
@override
|
||||
String get showFavoriteRadioStations => 'Afficher une rangee de vos stations de radio favorites';
|
||||
String get showFavoriteRadioStations =>
|
||||
'Afficher une rangee de vos stations de radio favorites';
|
||||
|
||||
@override
|
||||
String get showFavoritePodcasts => 'Afficher une rangee de vos podcasts favoris';
|
||||
String get showFavoritePodcasts =>
|
||||
'Afficher une rangee de vos podcasts favoris';
|
||||
|
||||
@override
|
||||
String get extractColorsFromArtwork => 'Extraire les couleurs des pochettes d\'albums et d\'artistes';
|
||||
String get extractColorsFromArtwork =>
|
||||
'Extraire les couleurs des pochettes d\'albums et d\'artistes';
|
||||
|
||||
@override
|
||||
String get chooseHomeScreenRows => 'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Ajoute aux favoris';
|
||||
@@ -721,13 +796,15 @@ class SFr extends S {
|
||||
String get podcasts => 'Podcasts';
|
||||
|
||||
@override
|
||||
String get podcastSupportComingSoon => 'Prise en charge des podcasts bientot disponible';
|
||||
String get podcastSupportComingSoon =>
|
||||
'Prise en charge des podcasts bientot disponible';
|
||||
|
||||
@override
|
||||
String get noPodcasts => 'Aucun podcast';
|
||||
|
||||
@override
|
||||
String get addPodcastsHint => 'Abonnez-vous a des podcasts dans Music Assistant';
|
||||
String get addPodcastsHint =>
|
||||
'Abonnez-vous a des podcasts dans Music Assistant';
|
||||
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
@@ -774,7 +851,8 @@ class SFr extends S {
|
||||
String get loadingChapters => 'Chargement des chapitres...';
|
||||
|
||||
@override
|
||||
String get noChapterInfoAvailable => 'Aucune information de chapitre disponible';
|
||||
String get noChapterInfoAvailable =>
|
||||
'Aucune information de chapitre disponible';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
@@ -785,13 +863,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 +880,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 +901,19 @@ class SFr extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get noSeriesAvailable => 'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
|
||||
String get noSeriesAvailable =>
|
||||
'Aucune série disponible dans votre bibliothèque de livres audio.\nTirez pour rafraichir.';
|
||||
|
||||
@override
|
||||
String get pullToLoadSeries => 'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
|
||||
String get pullToLoadSeries =>
|
||||
'Tirez vers le bas pour charger les séries\ndepuis Music Assistant';
|
||||
|
||||
@override
|
||||
String get search => 'Rechercher';
|
||||
|
||||
@override
|
||||
String get metadataApisDescription => 'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
|
||||
String get metadataApisDescription =>
|
||||
'Les images d\'artistes sont automatiquement recuperees depuis Deezer. Ajoutez les cles API ci-dessous pour les biographies d\'artistes et les descriptions d\'albums.';
|
||||
|
||||
@override
|
||||
String get lastFmApiKey => 'Cle API Last.fm';
|
||||
@@ -852,11 +936,18 @@ class SFr extends S {
|
||||
String get playOn => 'Lire sur...';
|
||||
|
||||
@override
|
||||
String get addAlbumToQueueOn => 'Ajouter l\'album a la file d\'attente sur...';
|
||||
String get addAlbumToQueueOn =>
|
||||
'Ajouter l\'album a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String get addToQueue => 'Add to queue';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String get startRadio => 'Start radio';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Demarrer la radio $name sur...';
|
||||
@@ -889,7 +980,8 @@ class SFr extends S {
|
||||
}
|
||||
|
||||
@override
|
||||
String get materialYouDescription => 'Utiliser les couleurs systeme (Android 12+)';
|
||||
String get materialYouDescription =>
|
||||
'Utiliser les couleurs systeme (Android 12+)';
|
||||
|
||||
@override
|
||||
String get accentColor => 'Couleur d\'accentuation';
|
||||
@@ -901,19 +993,22 @@ class SFr extends S {
|
||||
String get preferLocalPlayer => 'Préférer le lecteur local';
|
||||
|
||||
@override
|
||||
String get preferLocalPlayerDescription => 'Toujours sélectionner cet appareil en premier si disponible';
|
||||
String get preferLocalPlayerDescription =>
|
||||
'Toujours sélectionner cet appareil en premier si disponible';
|
||||
|
||||
@override
|
||||
String get smartSortPlayers => 'Tri intelligent';
|
||||
|
||||
@override
|
||||
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||
String get smartSortPlayersDescription =>
|
||||
'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitch => 'Disable Auto-Switch';
|
||||
|
||||
@override
|
||||
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
|
||||
String get disableAutoSwitchDescription =>
|
||||
'Never automatically switch to a different player';
|
||||
|
||||
@override
|
||||
String get playerStateUnavailable => 'Indisponible';
|
||||
@@ -945,7 +1040,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 +1057,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 +1070,22 @@ class SFr extends S {
|
||||
String get swipeToAdjustVolume => 'Balayez pour ajuster le volume';
|
||||
|
||||
@override
|
||||
String get selectPlayerHint => 'Choisissez un lecteur ou fermez en balayant vers le bas';
|
||||
String get selectPlayerHint =>
|
||||
'Choisissez un lecteur ou fermez en balayant vers le bas';
|
||||
|
||||
@override
|
||||
String get welcomeToEnsemble => 'Bienvenue dans Ensemble';
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
|
||||
String get welcomeMessage =>
|
||||
'Par defaut, votre telephone est le lecteur selectionne.\nTirez le mini-lecteur vers le bas pour sélectionner un autre lecteur.';
|
||||
|
||||
@override
|
||||
String get skip => 'Passer';
|
||||
|
||||
@override
|
||||
String get dismissPlayerHint => 'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
|
||||
String get dismissPlayerHint =>
|
||||
'Balayez vers le bas, touchez a l\'exterieur ou appuyez sur retour pour revenir';
|
||||
|
||||
@override
|
||||
String playingAlbum(String albumName) {
|
||||
@@ -994,19 +1101,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) {
|
||||
@@ -1112,4 +1222,39 @@ class SFr extends S {
|
||||
|
||||
@override
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get aboutAndSupport => 'À propos et Support';
|
||||
|
||||
@override
|
||||
String get supportDevelopment => 'Soutenir le Développement';
|
||||
|
||||
@override
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
|
||||
|
||||
@override
|
||||
String get sponsorOnGitHub => 'Sponsoriser sur GitHub';
|
||||
|
||||
@override
|
||||
String get buyMeAKoFi => 'Offrez-moi un Ko-fi';
|
||||
|
||||
@override
|
||||
String get aboutEnsemble => 'À propos d\'Ensemble';
|
||||
|
||||
@override
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get visitGitHubRepo => 'Visiter le Dépôt GitHub';
|
||||
|
||||
@override
|
||||
String get reportABug => 'Signaler un Bug';
|
||||
|
||||
@override
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -6,14 +6,25 @@ import 'package:provider/provider.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'providers/connection_provider.dart';
|
||||
import 'providers/library_provider.dart';
|
||||
import 'providers/locale_provider.dart';
|
||||
import 'providers/local_player_provider.dart';
|
||||
import 'providers/music_assistant_provider.dart';
|
||||
import 'providers/navigation_provider.dart';
|
||||
import 'providers/player_provider.dart';
|
||||
import 'providers/sleep_timer_provider.dart';
|
||||
import 'providers/ui_state_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'services/cache_service.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'services/database_service.dart';
|
||||
import 'services/image_helper_service.dart';
|
||||
import 'services/position_tracker.dart';
|
||||
import 'services/queue_manager_service.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'services/provider_filter_service.dart';
|
||||
import 'services/sync_service.dart';
|
||||
import 'services/audio/massiv_audio_handler.dart';
|
||||
import 'services/auth/auth_manager.dart';
|
||||
@@ -23,6 +34,7 @@ import 'services/music_assistant_api.dart' show MAConnectionState;
|
||||
import 'theme/theme_provider.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'theme/system_theme_helper.dart';
|
||||
import 'utils/player_sync_state.dart';
|
||||
import 'widgets/global_player_overlay.dart';
|
||||
|
||||
// Global audio handler instance
|
||||
@@ -125,6 +137,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
late MusicAssistantProvider _musicProvider;
|
||||
late ThemeProvider _themeProvider;
|
||||
late LocaleProvider _localeProvider;
|
||||
|
||||
// New multi-provider architecture
|
||||
late ConnectionProvider _connectionProvider;
|
||||
late UIStateProvider _uiStateProvider;
|
||||
late LibraryProvider _libraryProvider;
|
||||
late PlayerProvider _playerProvider;
|
||||
late LocalPlayerProvider _localPlayerProvider;
|
||||
late SleepTimerProvider _sleepTimerProvider;
|
||||
late NavigationProvider _navigationProvider;
|
||||
|
||||
// Shared services
|
||||
late final CacheService _cacheService = CacheService();
|
||||
late final PositionTracker _positionTracker = PositionTracker();
|
||||
late ImageHelperService _imageHelper;
|
||||
|
||||
final _hardwareVolumeService = HardwareVolumeService();
|
||||
StreamSubscription? _volumeUpSub;
|
||||
StreamSubscription? _volumeDownSub;
|
||||
@@ -137,9 +164,91 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_musicProvider = MusicAssistantProvider();
|
||||
_themeProvider = ThemeProvider();
|
||||
_localeProvider = LocaleProvider();
|
||||
_navigationProvider = NavigationProvider();
|
||||
_sleepTimerProvider = SleepTimerProvider();
|
||||
|
||||
// Initialize connection provider first (others depend on it)
|
||||
_connectionProvider = ConnectionProvider();
|
||||
_uiStateProvider = UIStateProvider();
|
||||
|
||||
// Initialize shared image helper service
|
||||
_imageHelper = ImageHelperService(
|
||||
api: _connectionProvider.api,
|
||||
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
|
||||
);
|
||||
|
||||
// Initialize library provider (needs API from connection)
|
||||
_libraryProvider = LibraryProvider(
|
||||
api: _connectionProvider.api,
|
||||
logger: DebugLogger(),
|
||||
cacheService: _cacheService,
|
||||
settings: null, // Will use static methods
|
||||
providerFilterService: ProviderFilterService(
|
||||
providerFilter: _connectionProvider.providerFilter,
|
||||
playerFilter: _connectionProvider.playerFilter,
|
||||
availableMusicProviders: [],
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [],
|
||||
podcasts: [],
|
||||
radioStations: [],
|
||||
),
|
||||
);
|
||||
|
||||
// Initialize local player provider
|
||||
_localPlayerProvider = LocalPlayerProvider(
|
||||
audioHandler: audioHandler,
|
||||
settings: null, // Will use static methods
|
||||
);
|
||||
|
||||
// Initialize player provider (depends on several others)
|
||||
// Note: queueManager will be set later to avoid circular dependency
|
||||
_playerProvider = PlayerProvider(
|
||||
api: _connectionProvider.api,
|
||||
logger: DebugLogger(),
|
||||
cacheService: _cacheService,
|
||||
settings: null, // Will use static methods
|
||||
positionTracker: _positionTracker,
|
||||
queueManager: null, // Will be set after initialization
|
||||
sleepTimerProvider: _sleepTimerProvider,
|
||||
imageHelper: _imageHelper,
|
||||
playerSyncState: PlayerSyncState(
|
||||
availablePlayers: [],
|
||||
castToSendspinIdMap: {},
|
||||
),
|
||||
audioHandler: audioHandler,
|
||||
);
|
||||
|
||||
// Create queue manager with functions that access provider state
|
||||
final queueManager = QueueManagerService(
|
||||
api: _connectionProvider.api,
|
||||
database: DatabaseService.instance,
|
||||
logger: DebugLogger(),
|
||||
getAvailablePlayers: () => _playerProvider.availablePlayers,
|
||||
getCastToSendspinMap: () => _playerProvider.castToSendspinIdMap,
|
||||
playTracks: (playerId, tracks, {int? startIndex, bool? clearQueue}) async {
|
||||
await _connectionProvider.api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue ?? true);
|
||||
},
|
||||
);
|
||||
|
||||
// Set the queue manager after creation (breaks circular dependency)
|
||||
_playerProvider.queueManager = queueManager;
|
||||
|
||||
// Initialize main provider (facade mode - sub-providers not yet wired)
|
||||
// Sub-providers have null API at construction time and no update mechanism,
|
||||
// so keep facade using its own internal logic until wiring is complete.
|
||||
_musicProvider = MusicAssistantProvider();
|
||||
|
||||
// Wire up player provider callbacks
|
||||
_playerProvider.onPlayerChanged = () {
|
||||
setState(() {}); // Trigger rebuild on player change
|
||||
};
|
||||
_playerProvider.onTrackChanged = () {
|
||||
setState(() {}); // Trigger rebuild on track change
|
||||
};
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initHardwareVolumeControl();
|
||||
// Listen to player selection changes to toggle volume interception
|
||||
@@ -252,9 +361,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// Core facade provider (will be removed after migration)
|
||||
ChangeNotifierProvider.value(value: _musicProvider),
|
||||
|
||||
// Theme providers
|
||||
ChangeNotifierProvider.value(value: _themeProvider),
|
||||
ChangeNotifierProvider.value(value: _localeProvider),
|
||||
ChangeNotifierProvider.value(value: _navigationProvider),
|
||||
|
||||
// New multi-provider architecture (available for gradual migration)
|
||||
ChangeNotifierProvider.value(value: _connectionProvider),
|
||||
ChangeNotifierProvider.value(value: _uiStateProvider),
|
||||
ChangeNotifierProvider.value(value: _libraryProvider),
|
||||
ChangeNotifierProvider.value(value: _playerProvider),
|
||||
ChangeNotifierProvider.value(value: _localPlayerProvider),
|
||||
ChangeNotifierProvider.value(value: _sleepTimerProvider),
|
||||
],
|
||||
child: Consumer2<ThemeProvider, LocaleProvider>(
|
||||
builder: (context, themeProvider, localeProvider, _) {
|
||||
@@ -329,8 +450,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
builder: (context, child) {
|
||||
// Wrap entire app with global player overlay
|
||||
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
||||
// Wrap entire app with global player overlay.
|
||||
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
|
||||
return Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (context) => GlobalPlayerOverlay(
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
home: const AppStartup(),
|
||||
),
|
||||
@@ -450,94 +580,120 @@ class _AppStartupState extends State<AppStartup> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettingsFirst();
|
||||
try {
|
||||
_loadSettingsFirst();
|
||||
} catch (e) {
|
||||
_logger.error('AppStartup init error: $e');
|
||||
// Fallback - mark as loaded so app can continue
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
_hasCompletedOnboarding = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load settings BEFORE checking connection.
|
||||
/// This ensures we know if this is a first-time user before transitioning.
|
||||
Future<void> _loadSettingsFirst() async {
|
||||
// Load onboarding state first - this is critical for determining
|
||||
// when to transition to HomeScreen
|
||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
try {
|
||||
// Load onboarding state first - this is critical for determining
|
||||
// when to transition to HomeScreen
|
||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
|
||||
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
|
||||
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
|
||||
} catch (e) {
|
||||
_logger.error('AppStartup settings load error: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
_hasCompletedOnboarding = true; // Default to true on error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Now check connection
|
||||
_checkAndConnect();
|
||||
}
|
||||
|
||||
Future<void> _checkAndConnect() async {
|
||||
final serverUrl = await SettingsService.getServerUrl();
|
||||
try {
|
||||
final serverUrl = await SettingsService.getServerUrl();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_savedServerUrl = serverUrl;
|
||||
});
|
||||
|
||||
// If we have a saved server URL, attempt auto-connection
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_savedServerUrl = serverUrl;
|
||||
});
|
||||
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
// If we have a saved server URL, attempt auto-connection
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
});
|
||||
|
||||
// Connection is handled by MusicAssistantProvider._initialize()
|
||||
// Just wait for it to complete or timeout
|
||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// For first-time users, we need to wait for both connection AND player selection
|
||||
// so the welcome overlay can appear immediately without home screen flash.
|
||||
// For returning users, just wait for connection.
|
||||
final needsWelcome = !_hasCompletedOnboarding;
|
||||
// Connection is handled by MusicAssistantProvider._initialize()
|
||||
// Just wait for it to complete or timeout
|
||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||
|
||||
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||
// Check connection state periodically
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
// For first-time users, we need to wait for both connection AND player selection
|
||||
// so the welcome overlay can appear immediately without home screen flash.
|
||||
// For returning users, just wait for connection.
|
||||
final needsWelcome = !_hasCompletedOnboarding;
|
||||
|
||||
if (needsWelcome) {
|
||||
// First-time user: wait for connection + player selection
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
||||
break;
|
||||
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||
// Check connection state periodically
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
|
||||
if (needsWelcome) {
|
||||
// First-time user: wait for connection + player selection
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Returning user: just wait for connection
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Returning user: just wait for connection
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
|
||||
if (provider.connectionState == MAConnectionState.error) {
|
||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.connectionState == MAConnectionState.error) {
|
||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||
break;
|
||||
if (!provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
_logger.error('AppStartup connection error: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
_connectionAttempted = true;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_connectionAttempted = true;
|
||||
});
|
||||
}
|
||||
_connectionAttempted = true;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@ class Player {
|
||||
final String? activeSource; // The currently active source for this player
|
||||
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
|
||||
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
|
||||
final String? activeQueue; // The active_queue from MA - has value when MA is controlling playback
|
||||
|
||||
Player({
|
||||
required this.playerId,
|
||||
@@ -35,6 +36,7 @@ class Player {
|
||||
this.activeSource,
|
||||
this.isExternalSource = false,
|
||||
this.appId,
|
||||
this.activeQueue,
|
||||
});
|
||||
|
||||
/// Create a copy of this Player with some fields replaced
|
||||
@@ -55,6 +57,7 @@ class Player {
|
||||
String? activeSource,
|
||||
bool? isExternalSource,
|
||||
String? appId,
|
||||
String? activeQueue,
|
||||
}) {
|
||||
return Player(
|
||||
playerId: playerId ?? this.playerId,
|
||||
@@ -73,6 +76,7 @@ class Player {
|
||||
activeSource: activeSource ?? this.activeSource,
|
||||
isExternalSource: isExternalSource ?? this.isExternalSource,
|
||||
appId: appId ?? this.appId,
|
||||
activeQueue: activeQueue ?? this.activeQueue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,50 +222,61 @@ class Player {
|
||||
// Parse active_source - the currently active source for this player
|
||||
final activeSource = json['active_source'] as String?;
|
||||
|
||||
// Detect external source (optical, Spotify, AirPlay, etc.)
|
||||
// Primary indicator: app_id - if not 'music_assistant', it's an external source
|
||||
// Secondary indicators: URI patterns and media_type
|
||||
// Parse active_queue - this is the key indicator for MA-controlled playback
|
||||
// When MA is playing: active_queue = 'uuid:...' or similar queue ID
|
||||
// When external source: active_queue = null
|
||||
final activeQueue = json['active_queue'] as String?;
|
||||
|
||||
// Parse app_id - helps distinguish external sources on DLNA players
|
||||
final appId = json['app_id'] as String?;
|
||||
|
||||
// Detect external source (optical, Spotify Connect, AirPlay, etc.)
|
||||
//
|
||||
// Detection logic priority:
|
||||
// 1. If app_id == 'music_assistant' -> MA is playing, NOT external
|
||||
// 2. If active_queue has a value -> MA has a queue, NOT external
|
||||
// (This handles Spotify as music provider where URI is spotify:// but MA controls playback)
|
||||
// 3. If app_id is set and NOT 'music_assistant' -> external source (e.g., 'http', 'spotify')
|
||||
// 4. Fallback: Check URI patterns for simple external IDs (optical, line_in, etc.)
|
||||
bool isExternalSource = false;
|
||||
|
||||
// Check app_id first - this is the most reliable indicator for DLNA players
|
||||
// When MA is playing: app_id = 'music_assistant'
|
||||
// When external source: app_id = 'http', 'spotify', etc.
|
||||
final appId = json['app_id'] as String?;
|
||||
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
|
||||
// app_id is set but not 'music_assistant' - external source is active
|
||||
// If MA explicitly says it's playing, trust that
|
||||
if (appId == 'music_assistant') {
|
||||
isExternalSource = false;
|
||||
}
|
||||
// If there's an active queue, MA is controlling playback (even with spotify:// URIs)
|
||||
else if (activeQueue != null && activeQueue.isNotEmpty) {
|
||||
isExternalSource = false;
|
||||
}
|
||||
// If app_id is set to something else (http, spotify, etc.), it's external
|
||||
else if (appId != null && appId.isNotEmpty) {
|
||||
isExternalSource = true;
|
||||
}
|
||||
|
||||
// Also check current_media for additional external source indicators
|
||||
if (!isExternalSource && json.containsKey('current_media')) {
|
||||
// Fallback: check current_media for simple external source indicators
|
||||
// Only check these when we have no app_id and no active_queue
|
||||
else if (json.containsKey('current_media')) {
|
||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||
if (currentMedia != null) {
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
|
||||
// External source indicators:
|
||||
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
|
||||
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
|
||||
// 3. Media type is 'unknown' (MA doesn't recognize the source)
|
||||
// Only check for simple external source identifiers (physical inputs)
|
||||
// Do NOT flag spotify://, airplay://, etc. as external here -
|
||||
// those should only be external if app_id indicates external control
|
||||
if (uri != null) {
|
||||
final uriLower = uri.toLowerCase();
|
||||
// Simple external source identifiers (no :// or /)
|
||||
// These are physical inputs that are always external
|
||||
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
|
||||
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
|
||||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
|
||||
uriLower == 'coaxial' || uriLower == 'toslink');
|
||||
// External streaming protocols
|
||||
final isExternalProtocol = uriLower.startsWith('spotify://') ||
|
||||
uriLower.startsWith('airplay://') ||
|
||||
uriLower.startsWith('cast://') ||
|
||||
uriLower.startsWith('bluetooth://');
|
||||
|
||||
isExternalSource = isSimpleExternalId || isExternalProtocol;
|
||||
isExternalSource = isSimpleExternalId;
|
||||
}
|
||||
|
||||
// Also check media_type - 'unknown' often indicates external source
|
||||
// Also check media_type - 'unknown' with non-MA URI indicates external source
|
||||
if (!isExternalSource && mediaType == 'unknown') {
|
||||
// If media_type is unknown and URI doesn't look like MA content, it's external
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
|
||||
isExternalSource = true;
|
||||
@@ -287,6 +302,7 @@ class Player {
|
||||
activeSource: activeSource,
|
||||
isExternalSource: isExternalSource,
|
||||
appId: appId,
|
||||
activeQueue: activeQueue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,6 +324,7 @@ class Player {
|
||||
if (activeSource != null) 'active_source': activeSource,
|
||||
'is_external_source': isExternalSource,
|
||||
if (appId != null) 'app_id': appId,
|
||||
if (activeQueue != null) 'active_queue': activeQueue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'media_item.dart';
|
||||
|
||||
class RecommendationFolder {
|
||||
final String itemId;
|
||||
final String provider;
|
||||
final String name;
|
||||
final String? uri;
|
||||
final List<MediaItem> items;
|
||||
|
||||
RecommendationFolder({
|
||||
required this.itemId,
|
||||
required this.provider,
|
||||
required this.name,
|
||||
this.uri,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
/// Unique ID for use in row ordering and settings
|
||||
/// Uses 'discovery:' prefix to distinguish from static row IDs
|
||||
String get rowId => 'discovery:$itemId';
|
||||
|
||||
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
|
||||
final itemsJson = json['items'] as List<dynamic>?;
|
||||
final parsedItems = itemsJson
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.map(_parseMediaItemJson)
|
||||
.toList() ?? <MediaItem>[];
|
||||
|
||||
return RecommendationFolder(
|
||||
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
|
||||
provider: json['provider'] as String? ?? 'unknown',
|
||||
name: json['name'] as String? ?? '',
|
||||
uri: json['uri'] as String?,
|
||||
items: parsedItems,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'item_id': itemId,
|
||||
'provider': provider,
|
||||
'name': name,
|
||||
if (uri != null) 'uri': uri,
|
||||
'items': items.map((item) => item.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Polymorphic parser for media items in recommendation folders
|
||||
/// Handles heterogeneous items (playlists, radio, etc.)
|
||||
MediaItem _parseMediaItemJson(Map<String, dynamic> json) {
|
||||
final mediaType = json['media_type'] as String? ?? '';
|
||||
|
||||
switch (mediaType) {
|
||||
case 'playlist':
|
||||
return Playlist.fromJson(json);
|
||||
case 'album':
|
||||
return Album.fromJson(json);
|
||||
case 'artist':
|
||||
return Artist.fromJson(json);
|
||||
case 'track':
|
||||
return Track.fromJson(json);
|
||||
case 'audiobook':
|
||||
return Audiobook.fromJson(json);
|
||||
default:
|
||||
// For radio, podcast, podcast_episode - use MediaItem base class
|
||||
return MediaItem.fromJson(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/library_status_service.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/provider_filter_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/sync_service.dart';
|
||||
|
||||
/// Provider for library data management.
|
||||
///
|
||||
/// Manages:
|
||||
/// - Artists, albums, tracks lists
|
||||
/// - Radio stations and podcasts
|
||||
/// - Library loading and refresh
|
||||
/// - Provider filtering
|
||||
/// - Background sync
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate library logic.
|
||||
class LibraryProvider extends ChangeNotifier {
|
||||
final MusicAssistantAPI? _api;
|
||||
final DebugLogger _logger;
|
||||
final CacheService _cacheService;
|
||||
final SettingsService? _settings;
|
||||
final ProviderFilterService _providerFilterService;
|
||||
|
||||
// Library data
|
||||
List<dynamic> _artists = [];
|
||||
List<dynamic> _albums = [];
|
||||
List<dynamic> _tracks = [];
|
||||
List<dynamic> _radioStations = [];
|
||||
List<dynamic> _podcasts = [];
|
||||
|
||||
// Getters
|
||||
List<dynamic> get artists => _artists;
|
||||
List<dynamic> get albums => _albums;
|
||||
List<dynamic> get tracks => _tracks;
|
||||
List<dynamic> get radioStations => _radioStations;
|
||||
List<dynamic> get podcasts => _podcasts;
|
||||
|
||||
// Expose typed lists for convenience
|
||||
List<Artist> get artistList => _artists.cast<Artist>();
|
||||
List<Album> get albumList => _albums.cast<Album>();
|
||||
List<Track> get trackList => _tracks.cast<Track>();
|
||||
|
||||
// Callback for when library status changes
|
||||
Function()? onLibraryChanged;
|
||||
|
||||
LibraryProvider({
|
||||
required MusicAssistantAPI? api,
|
||||
required DebugLogger logger,
|
||||
required CacheService cacheService,
|
||||
SettingsService? settings,
|
||||
required ProviderFilterService providerFilterService,
|
||||
}) : _api = api,
|
||||
_logger = logger,
|
||||
_cacheService = cacheService,
|
||||
_settings = settings,
|
||||
_providerFilterService = providerFilterService;
|
||||
|
||||
/// Load library data from API with caching
|
||||
Future<void> loadLibrary() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_logger.log('📚 Loading library from cache (instant)...');
|
||||
|
||||
// Load from database cache first (instant)
|
||||
final syncService = SyncService.instance;
|
||||
if (syncService.hasCache) {
|
||||
_albums = syncService.cachedAlbums;
|
||||
_artists = syncService.cachedArtists;
|
||||
_logger.log('📦 Loaded ${_albums.length} albums, ${_artists.length} artists from cache');
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Fetch tracks from API (not cached - too many items)
|
||||
try {
|
||||
_tracks = await _api!.getTracks(
|
||||
limit: LibraryConstants.maxLibraryItems,
|
||||
providerInstanceIds: _providerIdsForApiCalls(),
|
||||
);
|
||||
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to fetch tracks: $e');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Trigger background sync (non-blocking)
|
||||
_syncLibraryInBackground();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error loading library: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load artists from library
|
||||
Future<void> loadArtists({int? limit, int? offset, String? search, String? orderBy}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final artists = await _api!.getArtists(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
albumArtistsOnly: false,
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
// Update SyncService cache
|
||||
SyncService.instance.updateCachedArtists(artists);
|
||||
|
||||
_artists = artists;
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load artists: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load albums from library
|
||||
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId, String? orderBy}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final albums = await _api!.getAlbums(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
artistId: artistId,
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
// Update SyncService cache
|
||||
SyncService.instance.updateCachedAlbums(albums);
|
||||
|
||||
_albums = albums;
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load radio stations
|
||||
Future<void> loadRadioStations({String? orderBy}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load radio stations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load podcasts
|
||||
Future<void> loadPodcasts({String? orderBy}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
|
||||
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load podcasts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlists with provider filtering
|
||||
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly, String? orderBy}) async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
|
||||
return _providerFilterService.filterByProvider(playlists);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch playlists: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get audiobooks with provider filtering
|
||||
Future<List<Audiobook>> getAudiobooks({int? limit, bool? favoriteOnly}) async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
final audiobooks = await _api!.getAudiobooks(limit: limit, favoriteOnly: favoriteOnly ?? false);
|
||||
return _providerFilterService.filterByProvider(audiobooks);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch audiobooks: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync library data in background without blocking UI
|
||||
void _syncLibraryInBackground() {
|
||||
if (_api == null) return;
|
||||
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
// Listen for sync completion to update our lists
|
||||
void onSyncComplete() {
|
||||
if (syncService.status == SyncStatus.completed) {
|
||||
_albums = syncService.cachedAlbums;
|
||||
_artists = syncService.cachedArtists;
|
||||
_logger.log('🔄 Updated library from background sync: ${_albums.length} albums, ${_artists.length} artists');
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
onLibraryChanged?.call();
|
||||
}
|
||||
syncService.removeListener(onSyncComplete);
|
||||
}
|
||||
|
||||
syncService.addListener(onSyncComplete);
|
||||
syncService.syncFromApi(_api!, providerInstanceIds: _providerIdsForApiCalls());
|
||||
}
|
||||
|
||||
/// Sync library status to centralized service for reactive UI updates
|
||||
void _syncLibraryStatusToService() {
|
||||
final service = LibraryStatusService.instance;
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get provider instance IDs for API calls (respects user filter)
|
||||
List<String> _providerIdsForApiCalls() {
|
||||
// For now, always return empty list to use all available providers
|
||||
// Provider filtering can be re-enabled later with async approach
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Update the API reference
|
||||
void setApi(MusicAssistantAPI? api) {
|
||||
if (_api != api) {
|
||||
// Note: We don't dispose the old API here as it's managed by ConnectionProvider
|
||||
// This is just updating the reference
|
||||
}
|
||||
}
|
||||
|
||||
/// Load library from cache for instant display
|
||||
Future<void> loadFromCache() async {
|
||||
try {
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
if (!syncService.hasCache) {
|
||||
await syncService.loadFromCache();
|
||||
}
|
||||
|
||||
if (syncService.hasCache) {
|
||||
_albums = syncService.cachedAlbums;
|
||||
_artists = syncService.cachedArtists;
|
||||
_logger.log('📦 Pre-loaded library: ${_albums.length} albums, ${_artists.length} artists');
|
||||
_syncLibraryStatusToService();
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading library from cache: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../services/audio/massiv_audio_handler.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/device_id_service.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/pcm_audio_player.dart';
|
||||
import '../services/sendspin_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
/// Provider for local audio playback and Sendspin integration.
|
||||
///
|
||||
/// Manages:
|
||||
/// - LocalPlayerService for audio playback
|
||||
/// - SendspinService integration (MA 2.7.0b20+)
|
||||
/// - PcmAudioPlayer for raw audio streaming
|
||||
/// - Player registration with Music Assistant
|
||||
/// - Volume and power control
|
||||
/// - Event handling from Sendspin
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate local player logic.
|
||||
class LocalPlayerProvider extends ChangeNotifier {
|
||||
final AuthManager _authManager;
|
||||
final DebugLogger _logger;
|
||||
final SettingsService? _settings;
|
||||
|
||||
// Local player service
|
||||
late final LocalPlayerService _localPlayer;
|
||||
|
||||
// Sendspin service (MA 2.7.0b20+ replacement for builtin_player)
|
||||
SendspinService? _sendspinService;
|
||||
bool _sendspinConnected = false;
|
||||
|
||||
// PCM audio player for raw Sendspin audio streaming
|
||||
PcmAudioPlayer? _pcmAudioPlayer;
|
||||
|
||||
// Local player state
|
||||
bool _isLocalPlayerPowered = true;
|
||||
int _localPlayerVolume = VolumeConstants.max;
|
||||
bool _builtinPlayerAvailable = true;
|
||||
|
||||
// State reporting timer
|
||||
Timer? _localPlayerStateReportTimer;
|
||||
|
||||
// Audio handler (from audio_service package)
|
||||
final MassivAudioHandler _audioHandler;
|
||||
|
||||
// Server info
|
||||
String? _serverUrl;
|
||||
|
||||
// Callbacks
|
||||
Function()? onSendspinConnected;
|
||||
Function()? onSendspinDisconnected;
|
||||
Function(Map<String, dynamic>)? onLocalPlayerEvent;
|
||||
|
||||
LocalPlayerProvider({
|
||||
AuthManager? authManager,
|
||||
DebugLogger? logger,
|
||||
SettingsService? settings,
|
||||
required MassivAudioHandler audioHandler,
|
||||
}) : _authManager = authManager ?? AuthManager(),
|
||||
_logger = logger ?? DebugLogger(),
|
||||
_settings = settings,
|
||||
_audioHandler = audioHandler {
|
||||
_localPlayer = LocalPlayerService(_authManager);
|
||||
}
|
||||
|
||||
// Getters
|
||||
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
|
||||
int get localPlayerVolume => _localPlayerVolume;
|
||||
bool get sendspinConnected => _sendspinConnected;
|
||||
bool get builtinPlayerAvailable => _builtinPlayerAvailable;
|
||||
bool get isPcmPlaying => _sendspinConnected && _pcmAudioPlayer != null && _pcmAudioPlayer!.isPlaying;
|
||||
LocalPlayerService get localPlayer => _localPlayer;
|
||||
SendspinService? get sendspinService => _sendspinService;
|
||||
PcmAudioPlayer? get pcmAudioPlayer => _pcmAudioPlayer;
|
||||
|
||||
// ============================================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================================
|
||||
|
||||
/// Initialize local playback engine
|
||||
Future<void> initialize() async {
|
||||
await _localPlayer.initialize();
|
||||
_isLocalPlayerPowered = true;
|
||||
|
||||
// Wire up notification button callbacks
|
||||
_wireNotificationCallbacks();
|
||||
|
||||
_logger.log('🔊 Local player initialized');
|
||||
}
|
||||
|
||||
/// Register the built-in/local player with Music Assistant
|
||||
Future<String?> registerLocalPlayer() async {
|
||||
if (_serverUrl == null) {
|
||||
_logger.log('⚠️ Cannot register player: no server URL');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final deviceId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
final ownerName = await SettingsService.getOwnerName() ?? 'Ensemble User';
|
||||
|
||||
_logger.log('📱 Registering local player: $deviceId');
|
||||
|
||||
// Check if server uses Sendspin (MA 2.7.0b20+)
|
||||
if (_serverUsesSendspin()) {
|
||||
return await _registerSendspinPlayer(deviceId, ownerName);
|
||||
} else {
|
||||
return await _registerBuiltinPlayer(deviceId, ownerName);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to register local player: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if server version is >= 2.7.0b20 (uses Sendspin)
|
||||
bool _serverUsesSendspin() {
|
||||
// This would check the server version from API
|
||||
// For now, we'll try both methods
|
||||
return true; // Default to Sendspin for modern MA
|
||||
}
|
||||
|
||||
/// Register using Sendspin protocol (MA 2.7.0b20+)
|
||||
Future<String?> _registerSendspinPlayer(String deviceId, String ownerName) async {
|
||||
_logger.log('📡 Registering via Sendspin protocol...');
|
||||
|
||||
try {
|
||||
// Initialize Sendspin service
|
||||
_sendspinService = SendspinService(_serverUrl!);
|
||||
_builtinPlayerAvailable = false;
|
||||
|
||||
// Initialize PCM audio player
|
||||
_pcmAudioPlayer = PcmAudioPlayer();
|
||||
final pcmInitialized = await _pcmAudioPlayer!.initialize();
|
||||
if (!pcmInitialized) {
|
||||
_logger.log('⚠️ Failed to initialize PCM audio player');
|
||||
}
|
||||
|
||||
// Connect Sendspin events
|
||||
_wireSendspinEvents();
|
||||
|
||||
// Try direct connection first
|
||||
final localSendspinUrl = '$_serverUrl/sendspin/$deviceId';
|
||||
final connected = await _sendspinService!.connectWithUrl(localSendspinUrl);
|
||||
|
||||
if (!connected) {
|
||||
// Try proxy connection
|
||||
final proxyUrl = '$_serverUrl/sendspin/proxy/$deviceId';
|
||||
final proxyConnected = await _sendspinService!.connectWithUrl(proxyUrl, useProxyAuth: true);
|
||||
if (!proxyConnected) {
|
||||
_logger.log('⚠️ Sendspin connection failed, will retry on connect');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect PCM audio player to Sendspin audio stream
|
||||
if (_pcmAudioPlayer != null && pcmInitialized) {
|
||||
await _pcmAudioPlayer!.connectToStream(_sendspinService!.audioDataStream);
|
||||
}
|
||||
|
||||
_sendspinConnected = true;
|
||||
_startStateReporting();
|
||||
onSendspinConnected?.call();
|
||||
|
||||
_logger.log('✅ Sendspin player registered: $deviceId');
|
||||
return deviceId;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Sendspin registration failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register using legacy builtin_player protocol
|
||||
Future<String?> _registerBuiltinPlayer(String deviceId, String ownerName) async {
|
||||
_logger.log('📻 Registering via builtin_player protocol...');
|
||||
// Legacy registration would go here
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SENDSPIN EVENT HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
void _wireSendspinEvents() {
|
||||
if (_sendspinService == null) return;
|
||||
|
||||
_sendspinService!.onPlay = _handleSendspinPlay;
|
||||
_sendspinService!.onPause = _handleSendspinPause;
|
||||
_sendspinService!.onStop = _handleSendspinStop;
|
||||
_sendspinService!.onSeek = _handleSendspinSeek;
|
||||
_sendspinService!.onVolume = _handleSendspinVolume;
|
||||
_sendspinService!.onStreamStart = _handleSendspinStreamStart;
|
||||
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
|
||||
}
|
||||
|
||||
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) {
|
||||
_logger.log('📡 Sendspin: Play $streamUrl');
|
||||
_pcmAudioPlayer?.play();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinPause() {
|
||||
_logger.log('📡 Sendspin: Pause');
|
||||
_pcmAudioPlayer?.pause();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinStop() {
|
||||
_logger.log('📡 Sendspin: Stop');
|
||||
_pcmAudioPlayer?.stop();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinSeek(int positionSeconds) {
|
||||
_logger.log('📡 Sendspin: Seek to $positionSeconds');
|
||||
_pcmAudioPlayer?.resetPosition();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinVolume(int volumeLevel) {
|
||||
_logger.log('📡 Sendspin: Set volume to $volumeLevel');
|
||||
_localPlayerVolume = volumeLevel;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinStreamStart(Map<String, dynamic>? trackInfo) {
|
||||
_logger.log('📡 Sendspin: Stream start');
|
||||
_pcmAudioPlayer?.play();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleSendspinStreamEnd() {
|
||||
_logger.log('📡 Sendspin: Stream end');
|
||||
_pcmAudioPlayer?.stop();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOCAL PLAYER CONTROLS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> setPower(bool powered) async {
|
||||
_isLocalPlayerPowered = powered;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setVolume(int volumeLevel) async {
|
||||
_localPlayerVolume = volumeLevel;
|
||||
_localPlayer.setVolume(volumeLevel / 100);
|
||||
|
||||
// Report to Sendspin if connected
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(volume: volumeLevel);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> playUrl(String url) async {
|
||||
await _localPlayer.playUrl(url);
|
||||
|
||||
// Report to Sendspin if connected
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(playing: true, paused: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
await _localPlayer.pause();
|
||||
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(playing: false, paused: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _localPlayer.stop();
|
||||
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(playing: false, paused: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
await _localPlayer.seek(position);
|
||||
|
||||
if (_sendspinConnected && _sendspinService != null) {
|
||||
_sendspinService!.reportState(position: position.inSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NOTIFICATION CALLBACKS
|
||||
// ============================================================================
|
||||
|
||||
void _wireNotificationCallbacks() {
|
||||
_audioHandler.onPlay = () {
|
||||
_logger.log('🎵 Notification: Play pressed');
|
||||
// This would be handled by PlayerProvider
|
||||
};
|
||||
_audioHandler.onPause = () {
|
||||
_logger.log('🎵 Notification: Pause pressed');
|
||||
// This would be handled by PlayerProvider
|
||||
};
|
||||
_audioHandler.onSkipToNext = () {
|
||||
_logger.log('🎵 Notification: Skip to next pressed');
|
||||
// This would be handled by PlayerProvider
|
||||
};
|
||||
_audioHandler.onSkipToPrevious = () {
|
||||
_logger.log('🎵 Notification: Skip to previous pressed');
|
||||
// This would be handled by PlayerProvider
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STATE REPORTING
|
||||
// ============================================================================
|
||||
|
||||
void _startStateReporting() {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerStateReportTimer = Timer.periodic(
|
||||
Timings.localPlayerReportInterval,
|
||||
(_) => _reportState(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reportState() async {
|
||||
if (!_sendspinConnected || _sendspinService == null) return;
|
||||
|
||||
final isPlaying = _localPlayer.isPlaying;
|
||||
final position = _localPlayer.position.inSeconds;
|
||||
final volume = _localPlayerVolume;
|
||||
final muted = _localPlayerVolume == 0;
|
||||
|
||||
_sendspinService!.reportState(
|
||||
playing: isPlaying,
|
||||
paused: !isPlaying,
|
||||
position: position,
|
||||
volume: volume,
|
||||
muted: muted,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERVER URL MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
void setServerUrl(String? serverUrl) {
|
||||
_serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLEANUP
|
||||
// ============================================================================
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_pcmAudioPlayer?.dispose();
|
||||
_sendspinService?.dispose();
|
||||
_localPlayer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../providers/sleep_timer_provider.dart';
|
||||
import '../services/audio/massiv_audio_handler.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/image_helper_service.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/position_tracker.dart';
|
||||
import '../services/queue_manager_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/player_sort_utility.dart';
|
||||
import '../utils/player_sync_state.dart';
|
||||
|
||||
/// Provider for player management and playback controls.
|
||||
///
|
||||
/// Manages:
|
||||
/// - Player selection and available players list
|
||||
/// - Current playback state and track
|
||||
/// - Play/pause/stop/next/previous/seek controls
|
||||
/// - Queue operations
|
||||
/// - Position tracking
|
||||
/// - Sleep timer integration
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate player logic.
|
||||
class PlayerProvider extends ChangeNotifier {
|
||||
final MusicAssistantAPI? _api;
|
||||
final DebugLogger _logger;
|
||||
final CacheService _cacheService;
|
||||
final SettingsService? _settings;
|
||||
final PositionTracker _positionTracker;
|
||||
QueueManagerService? _queueManager;
|
||||
final SleepTimerProvider _sleepTimerProvider;
|
||||
final ImageHelperService _imageHelper;
|
||||
final PlayerSyncState _playerSyncState;
|
||||
|
||||
// Player state
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
Map<String, String> _castToSendspinIdMap = {};
|
||||
bool _selectPlayerInProgress = false;
|
||||
|
||||
// Current playback
|
||||
Track? _currentTrack;
|
||||
Audiobook? _currentAudiobook;
|
||||
String? _currentPodcastName;
|
||||
TrackMetadata? _pendingTrackMetadata;
|
||||
TrackMetadata? _currentNotificationMetadata;
|
||||
|
||||
// Timers
|
||||
Timer? _playerStateTimer;
|
||||
Timer? _notificationPositionTimer;
|
||||
Timer? _idleServiceTimer;
|
||||
|
||||
// Audio handler (MassivAudioHandler for custom notification methods)
|
||||
final MassivAudioHandler _audioHandler;
|
||||
|
||||
// Callbacks for coordination
|
||||
Function()? onPlayerChanged;
|
||||
Function()? onTrackChanged;
|
||||
Function()? onAudiobookChanged;
|
||||
|
||||
PlayerProvider({
|
||||
required MusicAssistantAPI? api,
|
||||
required DebugLogger logger,
|
||||
required CacheService cacheService,
|
||||
SettingsService? settings,
|
||||
required PositionTracker positionTracker,
|
||||
QueueManagerService? queueManager,
|
||||
required SleepTimerProvider sleepTimerProvider,
|
||||
required ImageHelperService imageHelper,
|
||||
required PlayerSyncState playerSyncState,
|
||||
required MassivAudioHandler audioHandler,
|
||||
}) : _api = api,
|
||||
_logger = logger,
|
||||
_cacheService = cacheService,
|
||||
_settings = settings,
|
||||
_positionTracker = positionTracker,
|
||||
_queueManager = queueManager,
|
||||
_sleepTimerProvider = sleepTimerProvider,
|
||||
_imageHelper = imageHelper,
|
||||
_playerSyncState = playerSyncState,
|
||||
_audioHandler = audioHandler {
|
||||
// Connect position tracker to sleep timer
|
||||
_positionTracker.positionStream.listen((position) {
|
||||
// Notify listeners for position updates
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
// Set up sleep timer expiration callback
|
||||
_sleepTimerProvider.onExpired = () {
|
||||
pausePlayer(_selectedPlayer?.playerId ?? '');
|
||||
};
|
||||
}
|
||||
|
||||
// Getters
|
||||
Player? get selectedPlayer => _selectedPlayer;
|
||||
List<Player> get availablePlayers => _availablePlayers;
|
||||
Track? get currentTrack => _currentTrack;
|
||||
Audiobook? get currentAudiobook => _currentAudiobook;
|
||||
String? get currentPodcastName => _currentPodcastName;
|
||||
bool get selectPlayerInProgress => _selectPlayerInProgress;
|
||||
Map<String, String> get castToSendspinIdMap => _castToSendspinIdMap;
|
||||
|
||||
/// Set the queue manager (can be set after initialization to avoid circular deps)
|
||||
set queueManager(QueueManagerService? manager) {
|
||||
_queueManager = manager;
|
||||
}
|
||||
|
||||
bool get isPlaying => _selectedPlayer?.state == 'playing';
|
||||
bool get isPaused => _selectedPlayer?.state == 'paused';
|
||||
bool get isStopped => _selectedPlayer?.state == 'idle' ||
|
||||
_selectedPlayer?.state == 'off' ||
|
||||
_selectedPlayer == null;
|
||||
|
||||
String? get currentArtworkUrl {
|
||||
if (_currentTrack != null) {
|
||||
return _imageHelper.getImageUrl(_currentTrack!, size: ImageSizes.highRes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER LOADING AND SELECTION
|
||||
// ============================================================================
|
||||
|
||||
/// Load players and auto-select the best one
|
||||
Future<void> loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
||||
try {
|
||||
// Check cache validity
|
||||
if (!forceRefresh &&
|
||||
!coldStart &&
|
||||
_cacheService.isPlayersCacheValid() &&
|
||||
_availablePlayers.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Cast-to-Sendspin mappings
|
||||
await _loadCastToSendspinMappings();
|
||||
|
||||
final allPlayers = await getPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
||||
|
||||
// Filter players
|
||||
_availablePlayers = _filterPlayers(allPlayers, builtinPlayerId);
|
||||
|
||||
// Handle Cast/Sendspin player switching
|
||||
await _handleSendspinPlayers();
|
||||
|
||||
// Sort players
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
_sortPlayersSync(smartSort, builtinPlayerId);
|
||||
|
||||
// Auto-select player if none selected or on cold start
|
||||
if (_selectedPlayer == null || coldStart) {
|
||||
final playerToSelect = _selectBestPlayer(builtinPlayerId);
|
||||
if (playerToSelect != null) {
|
||||
await selectPlayer(playerToSelect);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error loading players: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all players from API
|
||||
Future<List<Player>> getPlayers() async {
|
||||
return await _api?.getPlayers() ?? [];
|
||||
}
|
||||
|
||||
/// Select a player as the active player
|
||||
Future<void> selectPlayer(Player player, {bool skipNotify = false}) async {
|
||||
if (_selectPlayerInProgress) {
|
||||
_logger.log('⚠️ selectPlayer already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
_selectPlayerInProgress = true;
|
||||
|
||||
try {
|
||||
_selectedPlayer = player;
|
||||
|
||||
// Cache selection
|
||||
_cacheService.setCachedSelectedPlayer(player);
|
||||
await SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||
|
||||
// Set current track from cache for instant display
|
||||
_currentTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
|
||||
// Initialize position tracker
|
||||
_positionTracker.onPlayerSelected(player.playerId);
|
||||
if (!player.isExternalSource) {
|
||||
_positionTracker.updateFromServer(
|
||||
playerId: player.playerId,
|
||||
position: player.elapsedTime ?? 0.0,
|
||||
isPlaying: player.state == 'playing',
|
||||
duration: _currentTrack?.duration,
|
||||
serverTimestamp: player.elapsedTimeLastUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
// Update audio handler notification
|
||||
await _updateNotificationForPlayer(player);
|
||||
|
||||
// Start player state polling
|
||||
_startPlayerStatePolling();
|
||||
_manageNotificationPositionTimer();
|
||||
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
onPlayerChanged?.call();
|
||||
}
|
||||
} finally {
|
||||
_selectPlayerInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update audio handler notification for the selected player
|
||||
Future<void> _updateNotificationForPlayer(Player player) async {
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isBuiltinPlayer = builtinPlayerId != null && player.playerId == builtinPlayerId;
|
||||
|
||||
if (isBuiltinPlayer) {
|
||||
_audioHandler.setLocalMode();
|
||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _imageHelper.getImageUrl(track, size: ImageSizes.highRes);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
: player.name;
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: artistWithPlayer,
|
||||
album: track.album?.name ?? '',
|
||||
duration: track.duration,
|
||||
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
);
|
||||
_audioHandler.updateLocalModeNotification(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
duration: track.duration,
|
||||
);
|
||||
} else if (player.state == 'playing' || player.state == 'paused') {
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: 'player_${player.playerId}',
|
||||
title: player.name,
|
||||
artist: 'Loading...',
|
||||
);
|
||||
_audioHandler.updateLocalModeNotification(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
);
|
||||
} else {
|
||||
_audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
}
|
||||
} else {
|
||||
// Remote player
|
||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _imageHelper.getImageUrl(track, size: ImageSizes.highRes);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
: player.name;
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: artistWithPlayer,
|
||||
album: track.album?.name ?? '',
|
||||
duration: track.duration,
|
||||
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
);
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
_audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
position: position,
|
||||
duration: track.duration,
|
||||
);
|
||||
} else if (player.state == 'playing' || player.state == 'paused') {
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: 'player_${player.playerId}',
|
||||
title: player.name,
|
||||
artist: 'Loading...',
|
||||
);
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
_audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
position: position,
|
||||
duration: Duration.zero,
|
||||
);
|
||||
} else {
|
||||
_audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYBACK CONTROLS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> playPauseSelectedPlayer() async {
|
||||
if (_selectedPlayer == null) return;
|
||||
final playerId = _selectedPlayer!.playerId;
|
||||
if (_selectedPlayer?.state == 'playing') {
|
||||
await pausePlayer(playerId);
|
||||
} else {
|
||||
await playPlayer(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playPlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.resumePlayer(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to play: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pausePlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.pausePlayer(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to pause: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopPlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.stopPlayer(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to stop: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> nextTrack(String playerId) async {
|
||||
try {
|
||||
await _api?.nextTrack(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to skip to next: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> previousTrack(String playerId) async {
|
||||
try {
|
||||
await _api?.previousTrack(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to go to previous: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(String playerId, int positionSeconds) async {
|
||||
try {
|
||||
await _api?.seek(playerId, positionSeconds);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to seek: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seekRelative(String playerId, int deltaSeconds) async {
|
||||
final currentPosition = _positionTracker.currentPosition.inSeconds;
|
||||
await seek(playerId, (currentPosition + deltaSeconds).clamp(0, double.infinity).toInt());
|
||||
}
|
||||
|
||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||
try {
|
||||
await _api?.setVolume(playerId, volumeLevel);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to set volume: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TRACK PLAYBACK
|
||||
// ============================================================================
|
||||
|
||||
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||
try {
|
||||
await _api?.playTrack(playerId, track, clearQueue: clearQueue);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to play track: $e');
|
||||
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);
|
||||
|
||||
if (tracks.isNotEmpty && startIndex != null && startIndex < tracks.length) {
|
||||
_currentTrack = tracks[startIndex];
|
||||
} else if (tracks.isNotEmpty) {
|
||||
_currentTrack = tracks.first;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
onTrackChanged?.call();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to play tracks: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||
return _queueManager?.getQueue(playerId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POLLING AND STATE UPDATES
|
||||
// ============================================================================
|
||||
|
||||
void _startPlayerStatePolling() {
|
||||
_playerStateTimer?.cancel();
|
||||
_playerStateTimer = Timer.periodic(
|
||||
Timings.playerPollingInterval,
|
||||
(_) => _updatePlayerState(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updatePlayerState() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
try {
|
||||
final playerId = _selectedPlayer!.playerId;
|
||||
|
||||
// Get all players and find our selected one
|
||||
final allPlayers = await _api?.getPlayers();
|
||||
final updatedPlayer = allPlayers?.firstWhere(
|
||||
(p) => p.playerId == playerId,
|
||||
orElse: () => _selectedPlayer!,
|
||||
);
|
||||
|
||||
if (updatedPlayer == null) {
|
||||
_logger.log('⚠️ _updatePlayerState: Player not found: $playerId');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player changed during fetch
|
||||
if (_selectedPlayer?.playerId != playerId) {
|
||||
_logger.log('⚠️ _updatePlayerState: Player changed during fetch, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
final previousState = _selectedPlayer!.state;
|
||||
_selectedPlayer = updatedPlayer;
|
||||
|
||||
// Update position tracker
|
||||
_positionTracker.updateFromServer(
|
||||
playerId: playerId,
|
||||
position: updatedPlayer.elapsedTime ?? 0.0,
|
||||
isPlaying: updatedPlayer.state == 'playing',
|
||||
duration: _currentTrack?.duration,
|
||||
serverTimestamp: updatedPlayer.elapsedTimeLastUpdated,
|
||||
);
|
||||
|
||||
// Update notification if state changed
|
||||
if (previousState != updatedPlayer.state) {
|
||||
await _updateNotificationForPlayer(updatedPlayer);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error updating player state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _manageNotificationPositionTimer() {
|
||||
_notificationPositionTimer?.cancel();
|
||||
|
||||
// Only for remote/Sendspin players
|
||||
// Note: We don't have a sync version, so we use a cached value or null
|
||||
final builtinPlayerId = _selectedPlayer?.playerId; // Simplified for now
|
||||
final isRemotePlayer = _selectedPlayer != null &&
|
||||
(builtinPlayerId == null || _selectedPlayer!.playerId != builtinPlayerId);
|
||||
|
||||
if (isRemotePlayer && (_selectedPlayer?.state == 'playing' || _selectedPlayer?.state == 'paused')) {
|
||||
_notificationPositionTimer = Timer.periodic(
|
||||
Timings.notificationPositionUpdate,
|
||||
(_) => _updateNotificationPosition(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateNotificationPosition() {
|
||||
if (_currentTrack == null || _selectedPlayer == null) return;
|
||||
|
||||
final position = _positionTracker.currentPosition;
|
||||
// Update notification position
|
||||
// (This would be handled by audio_handler internally)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
Future<void> setSleepTimer(int? minutes) async {
|
||||
_sleepTimerProvider.setTimer(minutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cancelSleepTimer() async {
|
||||
_sleepTimerProvider.setTimer(null);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER METHODS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> _loadCastToSendspinMappings() async {
|
||||
if (_castToSendspinIdMap.isEmpty) {
|
||||
try {
|
||||
final persistedMappings = await DatabaseService.instance.getAllCastToSendspinMappings();
|
||||
_castToSendspinIdMap.addAll(persistedMappings);
|
||||
if (persistedMappings.isNotEmpty) {
|
||||
_logger.log('🔗 Loaded ${persistedMappings.length} Cast->Sendspin mappings from database');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load Cast->Sendspin mappings: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Player> _filterPlayers(List<Player> allPlayers, String? builtinPlayerId) {
|
||||
return PlayerSortUtility.filterPlayers(
|
||||
allPlayers,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSendspinPlayers() async {
|
||||
// Complex Sendspin/Cast player switching logic
|
||||
// This would need to be extracted fully
|
||||
// For now, we'll keep a simplified version
|
||||
}
|
||||
|
||||
void _sortPlayersSync(bool smartSort, String? builtinPlayerId) {
|
||||
PlayerSortUtility.sortPlayers(
|
||||
_availablePlayers,
|
||||
smartSort: smartSort,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
Player? _selectBestPlayer(String? builtinPlayerId) {
|
||||
return PlayerSortUtility.selectBestPlayer(
|
||||
availablePlayers: _availablePlayers,
|
||||
lastSelectedPlayer: null, // Could load from settings
|
||||
lastActivePlayer: null,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
void _startIdleServiceTimer() {
|
||||
_idleServiceTimer?.cancel();
|
||||
_idleServiceTimer = Timer(const Duration(minutes: 30), () {
|
||||
// After 30 min idle, stop position tracking
|
||||
_positionTracker.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelIdleServiceTimer() {
|
||||
_idleServiceTimer?.cancel();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLEANUP
|
||||
// ============================================================================
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playerStateTimer?.cancel();
|
||||
_notificationPositionTimer?.cancel();
|
||||
_idleServiceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class SleepTimerProvider with ChangeNotifier {
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||
Timer? _sleepTimerDisplayTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Callback to pause playback when timer expires
|
||||
OnSleepTimerExpired? onExpired;
|
||||
@@ -74,7 +75,9 @@ class SleepTimerProvider with ChangeNotifier {
|
||||
|
||||
// Start display update timer (every second for countdown)
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
if (!_disposed) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
}
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
@@ -123,6 +126,7 @@ class SleepTimerProvider with ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_cancelInternal();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
|
||||
/// Provider for UI-only state that doesn't belong to any specific domain.
|
||||
///
|
||||
/// Manages:
|
||||
/// - Search state (query and results)
|
||||
/// - Provider and player filters (from MA user settings)
|
||||
/// - User-controlled provider enable/disable
|
||||
/// - Loading states
|
||||
/// - Home refresh counter
|
||||
///
|
||||
/// This state is UI-focused and doesn't involve business logic operations.
|
||||
class UIStateProvider extends ChangeNotifier {
|
||||
// Search state
|
||||
String _lastSearchQuery = '';
|
||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
|
||||
// Provider filter from MA user settings (empty = all providers allowed)
|
||||
List<String> _providerFilter = [];
|
||||
|
||||
// Player filter from MA user settings (empty = all players allowed)
|
||||
List<String> _playerFilter = [];
|
||||
|
||||
// User-controlled music provider filter (local settings in Ensemble)
|
||||
// Empty list means all providers are enabled (no filtering)
|
||||
List<String> _enabledProviderIds = [];
|
||||
|
||||
// Available music providers discovered from MA
|
||||
List<ProviderInstance> _availableMusicProviders = [];
|
||||
|
||||
// Loading states
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingRadio = false;
|
||||
bool _isLoadingPodcasts = false;
|
||||
|
||||
// Home refresh counter - increments to signal home screen to refresh all rows
|
||||
int _homeRefreshCounter = 0;
|
||||
|
||||
// Getters
|
||||
String get lastSearchQuery => _lastSearchQuery;
|
||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
List<String> get enabledProviderIds => _enabledProviderIds;
|
||||
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingRadio => _isLoadingRadio;
|
||||
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
||||
int get homeRefreshCounter => _homeRefreshCounter;
|
||||
|
||||
/// Whether provider filtering is active
|
||||
bool get hasProviderFilter => _providerFilter.isNotEmpty;
|
||||
|
||||
/// Whether player filtering is active
|
||||
bool get hasPlayerFilter => _playerFilter.isNotEmpty;
|
||||
|
||||
/// Whether user provider filter is active
|
||||
bool get hasEnabledProviderFilter => _enabledProviderIds.isNotEmpty;
|
||||
|
||||
// Search state management
|
||||
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||
_lastSearchQuery = query;
|
||||
_lastSearchResults = results;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearSearchState() {
|
||||
_lastSearchQuery = '';
|
||||
_lastSearchResults = {'artists': [], 'albums': [], 'tracks': []};
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Filter management
|
||||
void setProviderFilter(List<String> filter) {
|
||||
_providerFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPlayerFilter(List<String> filter) {
|
||||
_playerFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setAvailableMusicProviders(List<ProviderInstance> providers) {
|
||||
_availableMusicProviders = providers;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setEnabledProviderIds(List<String> enabledIds) {
|
||||
_enabledProviderIds = enabledIds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Toggle a specific music provider on/off
|
||||
void toggleProviderEnabled(String instanceId, bool enabled) {
|
||||
final allIds = _availableMusicProviders.map((p) => p.instanceId).toList();
|
||||
|
||||
// Don't allow disabling the last provider
|
||||
if (!enabled && _enabledProviderIds.isNotEmpty && _enabledProviderIds.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state
|
||||
if (_enabledProviderIds.isEmpty && !enabled) {
|
||||
// Switch to explicit mode
|
||||
_enabledProviderIds = allIds.where((id) => id != instanceId).toList();
|
||||
} else if (enabled) {
|
||||
if (!_enabledProviderIds.contains(instanceId)) {
|
||||
_enabledProviderIds = [..._enabledProviderIds, instanceId];
|
||||
}
|
||||
} else {
|
||||
_enabledProviderIds = _enabledProviderIds.where((id) => id != instanceId).toList();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if a provider should be visible based on both filters
|
||||
bool isProviderEnabled(String instanceId) {
|
||||
// Check MA user filter (server-side restriction)
|
||||
if (hasProviderFilter && !_providerFilter.contains(instanceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check user filter (local preference)
|
||||
if (hasEnabledProviderFilter && !_enabledProviderIds.contains(instanceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Check if a player should be visible based on MA user filter
|
||||
bool isPlayerAllowed(String playerId) {
|
||||
if (!hasPlayerFilter) return true;
|
||||
return _playerFilter.contains(playerId);
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
void setLoading(bool loading) {
|
||||
_isLoading = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setLoadingRadio(bool loading) {
|
||||
_isLoadingRadio = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setLoadingPodcasts(bool loading) {
|
||||
_isLoadingPodcasts = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void incrementHomeRefreshCounter() {
|
||||
_homeRefreshCounter++;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -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.initLibraryStatus(key, true);
|
||||
}
|
||||
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
||||
service.initFavoriteStatus(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,161 +650,60 @@ 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);
|
||||
try {
|
||||
_logger.log('Adding album to queue on ${player.name}');
|
||||
await maProvider.playTracks(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: 0,
|
||||
clearQueue: false,
|
||||
);
|
||||
_logger.log('Album added to queue on ${player.name}');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.albumAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding album to queue: $e');
|
||||
_showError('Failed to add album to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.addAlbumToQueueOn,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
_logger.log('Adding album to queue on ${player.name}');
|
||||
await maProvider.addTracksToQueue(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
);
|
||||
_logger.log('Album added to queue on ${player.name}');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.albumAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
// Show mini player again when sheet closes
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding album to queue: $e');
|
||||
_showError('Failed to add album to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
// Add tracks from this index onwards to queue
|
||||
await maProvider.playTracks(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: index,
|
||||
clearQueue: false,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.tracksAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding to queue: $e');
|
||||
_showError('Failed to add to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.addToQueueOn,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
// Add tracks from this index onwards to queue
|
||||
await maProvider.addTracksToQueue(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: index,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.tracksAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
// Slide mini player back up when sheet is dismissed
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding to queue: $e');
|
||||
_showError('Failed to add to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToArtist() {
|
||||
@@ -928,7 +935,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -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,37 +1019,26 @@ 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),
|
||||
),
|
||||
),
|
||||
|
||||
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: Icon(
|
||||
Symbols.book_2,
|
||||
size: 25,
|
||||
fill: isInLibrary ? 1 : 0,
|
||||
color: isInLibrary
|
||||
? colorScheme.primary
|
||||
: Colors.white70,
|
||||
),
|
||||
child: const Icon(Icons.playlist_add),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1062,33 +1057,53 @@ 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,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Builder(
|
||||
builder: (buttonContext) => FilledButton.tonal(
|
||||
onPressed: () {
|
||||
if (_isLoading || _tracks.isEmpty) return;
|
||||
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
|
||||
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: position,
|
||||
mediaType: ContextMenuMediaType.album,
|
||||
item: widget.album,
|
||||
isFavorite: isFavorite,
|
||||
isInLibrary: isInLibrary,
|
||||
onToggleFavorite: _toggleFavorite,
|
||||
onToggleLibrary: _toggleLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
showTopRow: false, // Only show list items since buttons are already visible
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
size: 25,
|
||||
color: _isLoading || _tracks.isEmpty
|
||||
? Colors.white70.withOpacity(0.38)
|
||||
: Colors.white70,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_isInLibrary ? Icons.library_add_check : Icons.library_add,
|
||||
color: _isInLibrary
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1122,138 +1137,78 @@ 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(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${track.position ?? index + 1}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.track,
|
||||
item: track,
|
||||
isFavorite: track.favorite ?? false,
|
||||
isInLibrary: track.inLibrary,
|
||||
onToggleFavorite: () => _toggleTrackFavorite(index),
|
||||
onToggleLibrary: () => _toggleTrackLibrary(index),
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${track.position ?? index + 1}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
trailing: track.duration != null
|
||||
? Text(
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (HiResBadge.getTooltip(track) != null) ...[
|
||||
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (track.duration != null)
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
_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;
|
||||
});
|
||||
},
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: isExpanded
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Radio button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showPlayRadioMenu(context, index),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.radio, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Add to queue button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _addTrackToQueue(context, index),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.playlist_add, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Favorite button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _toggleTrackFavorite(index),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
track.favorite == true ? Icons.favorite : Icons.favorite_border,
|
||||
size: 20,
|
||||
color: track.favorite == true
|
||||
? colorScheme.error
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
onTap: () => _playTrack(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: _tracks.length,
|
||||
@@ -1309,6 +1264,80 @@ 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: () async {
|
||||
Navigator.pop(context);
|
||||
// Add to queue on selected player
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player != null) {
|
||||
try {
|
||||
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.albumAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
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';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'audiobook_detail_screen.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
|
||||
@@ -33,6 +37,7 @@ class AudiobookAuthorScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final _logger = DebugLogger();
|
||||
late List<Audiobook> _audiobooks;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
@@ -116,6 +121,149 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle favorite status for an audiobook
|
||||
Future<void> _toggleAudiobookFavorite(Audiobook book) async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !(book.favorite ?? false);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (newState) {
|
||||
// Add to favorites
|
||||
String actualProvider = book.provider;
|
||||
String actualItemId = book.itemId;
|
||||
|
||||
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
|
||||
final mapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => book.providerMappings!.firstWhere(
|
||||
(m) => m.available,
|
||||
orElse: () => book.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
success = await maProvider.addToFavorites(
|
||||
mediaType: 'audiobook',
|
||||
itemId: actualItemId,
|
||||
provider: actualProvider,
|
||||
);
|
||||
} else {
|
||||
// Remove from favorites
|
||||
int? libraryItemId;
|
||||
if (book.provider == 'library') {
|
||||
libraryItemId = int.tryParse(book.itemId);
|
||||
} else if (book.providerMappings != null) {
|
||||
final libraryMapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => book.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() {
|
||||
final index = _audiobooks.indexWhere((b) => b.itemId == book.itemId);
|
||||
if (index != -1) {
|
||||
_audiobooks[index] = Audiobook(
|
||||
itemId: book.itemId,
|
||||
provider: book.provider,
|
||||
name: book.name,
|
||||
sortName: book.sortName,
|
||||
uri: book.uri,
|
||||
providerMappings: book.providerMappings,
|
||||
favorite: newState,
|
||||
metadata: book.metadata,
|
||||
year: book.year,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling audiobook favorite: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle library status for an audiobook
|
||||
Future<void> _toggleAudiobookLibrary(Audiobook book) async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !book.inLibrary;
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (newState) {
|
||||
// Add to library
|
||||
String? actualProvider;
|
||||
String? actualItemId;
|
||||
|
||||
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
|
||||
final mapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => book.providerMappings!.first,
|
||||
);
|
||||
if (mapping.providerInstance != 'library') {
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
}
|
||||
|
||||
if (actualProvider != null && actualItemId != null) {
|
||||
success = await maProvider.addToLibrary(
|
||||
mediaType: 'audiobook',
|
||||
itemId: actualItemId,
|
||||
provider: actualProvider,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
if (book.provider == 'library') {
|
||||
libraryItemId = int.tryParse(book.itemId);
|
||||
} else if (book.providerMappings != null) {
|
||||
final libraryMapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => book.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Refresh would require reloading from parent - for now just show success
|
||||
_logger.log('Audiobook library status toggled');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling audiobook library: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||
@@ -150,9 +298,9 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||
final expandedHeight = coverSize + 100;
|
||||
// Responsive cover size: 50% of screen width, clamped between 140-200 (matches artist detail)
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
|
||||
final expandedHeight = coverSize + 60;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -220,77 +368,84 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.authorName,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
// Author info on the left
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.authorName,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.audiobookCount(_audiobooks.length),
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.audiobookCount(_audiobooks.length),
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
// Three-dot Menu Button on the right
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: Builder(
|
||||
builder: (buttonContext) => FilledButton.tonal(
|
||||
onPressed: () {
|
||||
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
|
||||
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: position,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
|
||||
isFavorite: false,
|
||||
isInLibrary: false,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
showTopRow: false,
|
||||
sortOrder: _sortOrder,
|
||||
onToggleSort: _toggleSortOrder,
|
||||
viewMode: _viewMode,
|
||||
onCycleView: _cycleViewMode,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
size: 25,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Audiobooks Section Header with controls
|
||||
// Audiobooks Section Header
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.audiobooks,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Sort toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
|
||||
onPressed: _toggleSortOrder,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// View mode toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_viewMode == 'list'
|
||||
? Icons.view_list
|
||||
: _viewMode == 'grid3'
|
||||
? Icons.grid_view
|
||||
: Icons.grid_on,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _viewMode == 'grid2'
|
||||
? S.of(context)!.threeColumnGrid
|
||||
: _viewMode == 'grid3'
|
||||
? S.of(context)!.listView
|
||||
: S.of(context)!.twoColumnGrid,
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0),
|
||||
child: Text(
|
||||
S.of(context)!.audiobooks,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -344,10 +499,31 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final heroSuffix = 'author${_heroTagSuffix}';
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Get adaptive scheme for context menu
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final adaptiveScheme = themeProvider.adaptiveTheme
|
||||
? (isDark ? _darkColorScheme : _lightColorScheme)
|
||||
: null;
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: book,
|
||||
isFavorite: book.favorite ?? false,
|
||||
isInLibrary: book.inLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
onToggleFavorite: () => _toggleAudiobookFavorite(book),
|
||||
onToggleLibrary: () => _toggleAudiobookLibrary(book),
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
@@ -438,7 +614,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -450,9 +627,30 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final heroSuffix = 'author${_heroTagSuffix}';
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
leading: Hero(
|
||||
// Get adaptive scheme for context menu
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final adaptiveScheme = themeProvider.adaptiveTheme
|
||||
? (isDark ? _darkColorScheme : _lightColorScheme)
|
||||
: null;
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: book,
|
||||
isFavorite: book.favorite ?? false,
|
||||
isInLibrary: book.inLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
onToggleFavorite: () => _toggleAudiobookFavorite(book),
|
||||
onToggleLibrary: () => _toggleAudiobookLibrary(book),
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
leading: Hero(
|
||||
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -533,7 +731,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/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();
|
||||
}
|
||||
});
|
||||
if (mounted) {
|
||||
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>();
|
||||
|
||||
@@ -556,7 +652,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -364,7 +364,6 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly),
|
||||
child: FloatingActionButton(
|
||||
onPressed: () {
|
||||
setState(() {});
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
|
||||
@@ -6,12 +6,52 @@ import '../widgets/album_card.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class LibraryAlbumsScreen extends StatelessWidget {
|
||||
class LibraryAlbumsScreen extends StatefulWidget {
|
||||
const LibraryAlbumsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LibraryAlbumsScreen> createState() => _LibraryAlbumsScreenState();
|
||||
}
|
||||
|
||||
class _LibraryAlbumsScreenState extends State<LibraryAlbumsScreen> {
|
||||
List<Album> _albums = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
_albums = provider.albums;
|
||||
_isLoading = provider.isLoading;
|
||||
provider.addListener(_onProviderChanged);
|
||||
|
||||
// Auto-load library if empty
|
||||
if (_albums.isEmpty && !_isLoading) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
provider.loadLibrary();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
context.read<MusicAssistantProvider>().removeListener(_onProviderChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onProviderChanged() {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
setState(() {
|
||||
_albums = provider.albums;
|
||||
_isLoading = provider.isLoading;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use Selector for targeted rebuilds - only rebuild when albums or loading state changes
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
@@ -34,13 +74,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Selector<MusicAssistantProvider, (List<Album>, bool)>(
|
||||
selector: (_, provider) => (provider.albums, provider.isLoading),
|
||||
builder: (context, data, _) {
|
||||
final (albums, isLoading) = data;
|
||||
return _buildAlbumsList(context, albums, isLoading);
|
||||
},
|
||||
),
|
||||
body: _buildAlbumsList(context, _albums, _isLoading),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,4 +127,3 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,52 @@ import '../widgets/artist_avatar.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class LibraryArtistsScreen extends StatelessWidget {
|
||||
class LibraryArtistsScreen extends StatefulWidget {
|
||||
const LibraryArtistsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LibraryArtistsScreen> createState() => _LibraryArtistsScreenState();
|
||||
}
|
||||
|
||||
class _LibraryArtistsScreenState extends State<LibraryArtistsScreen> {
|
||||
List<Artist> _artists = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
_artists = provider.artists;
|
||||
_isLoading = provider.isLoading;
|
||||
provider.addListener(_onProviderChanged);
|
||||
|
||||
// Auto-load library if empty
|
||||
if (_artists.isEmpty && !_isLoading) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
provider.loadLibrary();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
context.read<MusicAssistantProvider>().removeListener(_onProviderChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onProviderChanged() {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
setState(() {
|
||||
_artists = provider.artists;
|
||||
_isLoading = provider.isLoading;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
@@ -36,13 +76,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
||||
selector: (_, provider) => (provider.artists, provider.isLoading),
|
||||
builder: (context, data, _) {
|
||||
final (artists, isLoading) = data;
|
||||
return _buildArtistsList(context, artists, isLoading);
|
||||
},
|
||||
),
|
||||
body: _buildArtistsList(context, _artists, _isLoading),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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://')) {
|
||||
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();
|
||||
// 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 {
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
url = 'https://$url';
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Auth detection failed: ${e.toString()}';
|
||||
_isDetectingAuth = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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,173 +112,73 @@ 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();
|
||||
// Authenticate with MA
|
||||
_logger.log('Authenticating with Music Assistant...');
|
||||
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
// Check if we have a stored MA token first
|
||||
final storedToken = await SettingsService.getMaAuthToken();
|
||||
bool authSuccess = false;
|
||||
|
||||
if (storedToken != null && provider.api != null) {
|
||||
_logger.log('Trying stored MA token...');
|
||||
authSuccess = await provider.api!.authenticateWithToken(storedToken);
|
||||
}
|
||||
|
||||
if (!authSuccess) {
|
||||
_logger.log('Logging in with credentials...');
|
||||
final accessToken = await provider.api?.loginWithCredentials(username, password);
|
||||
|
||||
if (accessToken == null) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.pleaseEnterCredentials;
|
||||
_error = S.of(context)!.maLoginFailed;
|
||||
_isConnecting = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_addDebugLog('Authenticating with Music Assistant...');
|
||||
// Try to create a long-lived token for future use
|
||||
final longLivedToken = await provider.api?.createLongLivedToken();
|
||||
final tokenToStore = longLivedToken ?? accessToken;
|
||||
|
||||
// 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...');
|
||||
authSuccess = await provider.api!.authenticateWithToken(storedToken);
|
||||
}
|
||||
|
||||
if (!authSuccess) {
|
||||
_addDebugLog('Logging in with credentials...');
|
||||
// Login with credentials over WebSocket
|
||||
final accessToken = await provider.api?.loginWithCredentials(username, password);
|
||||
|
||||
if (accessToken == null) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.maLoginFailed;
|
||||
_isConnecting = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to create a long-lived token for future use
|
||||
final longLivedToken = await provider.api?.createLongLivedToken();
|
||||
final tokenToStore = longLivedToken ?? accessToken;
|
||||
|
||||
// Save the token for future auto-login
|
||||
await SettingsService.setMaAuthToken(tokenToStore);
|
||||
_addDebugLog('MA token saved for future logins');
|
||||
}
|
||||
|
||||
// Save credentials for future auto-login
|
||||
await SettingsService.setUsername(username);
|
||||
await SettingsService.setPassword(password);
|
||||
|
||||
// Save auth strategy info
|
||||
await SettingsService.setAuthCredentials({
|
||||
'strategy': 'music_assistant',
|
||||
'data': {'username': username},
|
||||
});
|
||||
// Save the token for future auto-login
|
||||
await SettingsService.setMaAuthToken(tokenToStore);
|
||||
_logger.log('MA token saved for future logins');
|
||||
}
|
||||
|
||||
// Wait for connection and authentication to complete
|
||||
// MA auth happens asynchronously after connect, so we need to wait longer
|
||||
// Save credentials
|
||||
await SettingsService.setUsername(username);
|
||||
await SettingsService.setPassword(password);
|
||||
await SettingsService.setServerUrl(serverUrl);
|
||||
|
||||
// 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 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,76 +265,29 @@ 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
|
||||
// Username
|
||||
Text(
|
||||
S.of(context)!.portOptional,
|
||||
S.of(context)!.username,
|
||||
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,
|
||||
controller: _usernameController,
|
||||
focusNode: _usernameFocusNode,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: S.of(context)!.portHint,
|
||||
hintText: S.of(context)!.username,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
@@ -616,248 +296,50 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.settings_ethernet_rounded,
|
||||
Icons.person_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(),
|
||||
enabled: !_isConnecting,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
|
||||
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),
|
||||
// Password
|
||||
Text(
|
||||
S.of(context)!.password,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: S.of(context)!.password,
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
obscureText: true,
|
||||
enabled: !_isConnecting,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _connect(),
|
||||
),
|
||||
|
||||
// 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),
|
||||
],
|
||||
|
||||
Text(
|
||||
S.of(context)!.username,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
focusNode: _usernameFocusNode,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: S.of(context)!.username,
|
||||
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,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Text(
|
||||
S.of(context)!.password,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: S.of(context)!.password,
|
||||
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.lock_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
obscureText: true,
|
||||
enabled: !_isConnecting,
|
||||
textInputAction: TextInputAction.done,
|
||||
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,44 +392,22 @@ 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _isConnecting
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
|
||||
),
|
||||
)
|
||||
: _isConnecting
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
: Text(
|
||||
S.of(context)!.connect,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -16,6 +17,7 @@ import '../widgets/series_row.dart';
|
||||
import '../widgets/playlist_row.dart';
|
||||
import '../widgets/radio_station_row.dart';
|
||||
import '../widgets/podcast_row.dart';
|
||||
import '../widgets/discovery_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -47,6 +49,11 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showDiscoverSeries = false;
|
||||
// Row order (loaded from settings)
|
||||
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||
// Discovery folders (dynamic rows from provider recommendations)
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
bool _isLoadingDiscoveryFolders = false;
|
||||
// Discovery row preferences (itemId -> enabled)
|
||||
Map<String, bool> _discoveryRowEnabled = {};
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -56,6 +63,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
SyncStatus? _lastSyncStatus;
|
||||
int _lastArtistCount = 0;
|
||||
MusicAssistantProvider? _providerListeningTo;
|
||||
int _lastHomeRefreshCounter = 0; // Track home refresh counter to force row refreshes
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -66,12 +74,18 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
SyncService.instance.addListener(_onSyncChanged);
|
||||
_lastSyncStatus = SyncService.instance.status;
|
||||
_checkInitialFavoriteState();
|
||||
|
||||
// Always listen to provider for home refresh counter changes
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
_lastHomeRefreshCounter = provider.homeRefreshCounter;
|
||||
_providerListeningTo = provider;
|
||||
provider.addListener(_onProviderChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SyncService.instance.removeListener(_onSyncChanged);
|
||||
// Clean up provider listener if attached
|
||||
// Clean up provider listener
|
||||
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -96,21 +110,35 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
});
|
||||
}
|
||||
|
||||
/// Called when MusicAssistantProvider changes (for cache load detection)
|
||||
/// Called when MusicAssistantProvider changes (for cache load detection and home refresh)
|
||||
void _onProviderChanged() {
|
||||
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
|
||||
final currentCount = _providerListeningTo!.artists.length;
|
||||
// If artists went from 0 to non-zero, refresh
|
||||
if (_lastArtistCount == 0 && currentCount > 0) {
|
||||
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
_providerListeningTo!.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
if (!mounted || _providerListeningTo == null) return;
|
||||
|
||||
final provider = _providerListeningTo!;
|
||||
final currentRefreshCounter = provider.homeRefreshCounter;
|
||||
|
||||
// Check if home refresh counter changed (indicates authentication completed and cache was invalidated)
|
||||
if (currentRefreshCounter != _lastHomeRefreshCounter) {
|
||||
_logger.log('🔄 Home: refresh counter changed ($_lastHomeRefreshCounter -> $currentRefreshCounter), refreshing all rows');
|
||||
_lastHomeRefreshCounter = currentRefreshCounter;
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
_refreshKey = UniqueKey(); // Forces all rows to rebuild and re-fetch data
|
||||
});
|
||||
return; // Don't process other refresh conditions in this cycle
|
||||
}
|
||||
|
||||
// Check for artists cache load (when favorites were empty on load)
|
||||
if (_hadEmptyFavoritesOnLoad) {
|
||||
final currentCount = provider.artists.length;
|
||||
if (_lastArtistCount == 0 && currentCount > 0) {
|
||||
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
}
|
||||
_lastArtistCount = currentCount;
|
||||
}
|
||||
_lastArtistCount = currentCount;
|
||||
}
|
||||
|
||||
/// Called when SyncService status changes
|
||||
@@ -156,6 +184,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -171,8 +200,91 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
});
|
||||
}
|
||||
|
||||
// Load discovery folders dynamically (after settings load)
|
||||
// This will add enabled discovery rows to the home order
|
||||
await _loadDiscoveryFolders(forceRefresh: false);
|
||||
}
|
||||
|
||||
Future<void> _loadDiscoveryFolders({bool forceRefresh = false}) async {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Set loading state if not already loaded
|
||||
if (_discoveryFolders.isEmpty && mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = true;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
var folders = await provider.getDiscoveryFoldersWithCache();
|
||||
// If cached result is empty, force a refresh to get from API/database
|
||||
if (folders.isEmpty && !forceRefresh) {
|
||||
_logger.log('🔄 Discovery: Cached folders empty, forcing refresh...');
|
||||
folders = await provider.getDiscoveryFoldersWithCache(forceRefresh: true);
|
||||
}
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryFolders = folders;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
// Add enabled discovery rows to home row order if not already present
|
||||
if (mounted) {
|
||||
_addDiscoveryRowsToHomeOrder(folders);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load discovery folders: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add discovery rows to the home row order (at the end, like static rows)
|
||||
void _addDiscoveryRowsToHomeOrder(List<RecommendationFolder> folders) {
|
||||
if (!mounted) return;
|
||||
|
||||
bool didModify = false;
|
||||
|
||||
_logger.log('🔍 Discovery: Processing ${folders.length} folders, prefs: $_discoveryRowEnabled');
|
||||
|
||||
for (final folder in folders) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
// Check if this discovery row is enabled (default to false)
|
||||
final isEnabled = _discoveryRowEnabled[folder.itemId] ?? false;
|
||||
|
||||
_logger.log('🔍 Discovery: ${folder.name} (${folder.itemId}) enabled=$isEnabled, inOrder=${_homeRowOrder.contains(discoveryRowId)}');
|
||||
|
||||
if (isEnabled && !_homeRowOrder.contains(discoveryRowId)) {
|
||||
// Add at the end (consistent with static rows behavior)
|
||||
_homeRowOrder.add(discoveryRowId);
|
||||
_logger.log('✅ Discovery: Adding $discoveryRowId to home order at end');
|
||||
didModify = true;
|
||||
} else if (!isEnabled && _homeRowOrder.contains(discoveryRowId)) {
|
||||
// Remove disabled discovery row from order
|
||||
_homeRowOrder.remove(discoveryRowId);
|
||||
_logger.log('❌ Discovery: Removing $discoveryRowId from home order');
|
||||
didModify = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated row order to settings and trigger rebuild
|
||||
if (didModify) {
|
||||
_logger.log('💾 Discovery: Saving updated home row order: $_homeRowOrder');
|
||||
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||
if (mounted) {
|
||||
setState(() {}); // Trigger rebuild to show new row order
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
@@ -326,11 +438,34 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
for (final rowId in _homeRowOrder) {
|
||||
if (_isRowEnabled(rowId)) count++;
|
||||
}
|
||||
// Count discovery folders that are NOT in the main row order yet (legacy support during load)
|
||||
// This handles the brief moment between when discovery folders load and when they're added to the order
|
||||
if (_discoveryFolders.isNotEmpty) {
|
||||
for (final folder in _discoveryFolders) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
if (!_homeRowOrder.contains(discoveryRowId) && (_discoveryRowEnabled[folder.itemId] ?? false)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
} else if (_isLoadingDiscoveryFolders && _discoveryFolders.isEmpty) {
|
||||
// Count as 1 row when loading so RefreshIndicator works
|
||||
// Only if no discovery rows are in the main order
|
||||
final hasDiscoveryRowsInOrder = _homeRowOrder.any((id) => id.startsWith('discovery:'));
|
||||
if (!hasDiscoveryRowsInOrder) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Check if a specific row is enabled
|
||||
bool _isRowEnabled(String rowId) {
|
||||
// Discovery folders use the pattern 'discovery:{itemId}'
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
// Default to true if not explicitly set
|
||||
return _discoveryRowEnabled[itemId] ?? false;
|
||||
}
|
||||
switch (rowId) {
|
||||
case 'recent-albums': return _showRecentAlbums;
|
||||
case 'discover-artists': return _showDiscoverArtists;
|
||||
@@ -348,6 +483,57 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a loading placeholder for discovery rows
|
||||
Widget _buildDiscoveryLoadingPlaceholder(double rowHeight) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return SizedBox(
|
||||
height: rowHeight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
S.of(context)!.discoveryMixes,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
S.of(context)!.loading,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectedView(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
// Use LayoutBuilder to get available screen height
|
||||
@@ -386,10 +572,14 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
|
||||
child: Column(
|
||||
key: _refreshKey,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildOrderedRows(provider, rowHeight),
|
||||
child: SizedBox(
|
||||
// Ensure minimum height for pull-to-refresh when empty
|
||||
height: enabledRows == 0 ? availableHeight : null,
|
||||
child: Column(
|
||||
key: _refreshKey,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildOrderedRows(provider, rowHeight, enabledRows == 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -399,9 +589,54 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
}
|
||||
|
||||
/// Build rows in the user's configured order
|
||||
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
|
||||
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight, bool isEmpty) {
|
||||
final rows = <Widget>[];
|
||||
|
||||
// Show empty state with refresh hint when no rows are enabled
|
||||
if (isEmpty) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
rows.add(
|
||||
SizedBox.expand(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.home_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
S.of(context)!.noRowsEnabled,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.pullToRefreshHint,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Track which discovery folders have been rendered (from main row order)
|
||||
final renderedDiscoveryFolders = <String>{};
|
||||
|
||||
for (final rowId in _homeRowOrder) {
|
||||
final widget = _buildRowWidget(rowId, provider, rowHeight);
|
||||
if (widget != null) {
|
||||
@@ -410,6 +645,42 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
rows.add(const SizedBox(height: 2.0));
|
||||
}
|
||||
rows.add(widget);
|
||||
// Track discovery folders that were rendered from main row order
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
renderedDiscoveryFolders.add(rowId.substring('discovery:'.length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add legacy discovery folders that aren't in the main row order yet
|
||||
// This ensures existing discovery folders still show even if not yet in _homeRowOrder
|
||||
if (_discoveryFolders.isNotEmpty) {
|
||||
for (final folder in _discoveryFolders) {
|
||||
if (!renderedDiscoveryFolders.contains(folder.itemId)) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
// Check if this specific discovery row is enabled
|
||||
final isEnabled = _discoveryRowEnabled[folder.itemId] ?? false;
|
||||
if (isEnabled) {
|
||||
final widget = _buildRowWidget(discoveryRowId, provider, rowHeight);
|
||||
if (widget != null) {
|
||||
// Add spacing between rows
|
||||
if (rows.isNotEmpty) {
|
||||
rows.add(const SizedBox(height: 2.0));
|
||||
}
|
||||
rows.add(widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (_isLoadingDiscoveryFolders && _discoveryFolders.isEmpty) {
|
||||
// Show loading placeholder when discovery folders are loading
|
||||
// Only if no discovery rows are in the main order
|
||||
final hasDiscoveryRowsInOrder = _homeRowOrder.any((id) => id.startsWith('discovery:'));
|
||||
if (!hasDiscoveryRowsInOrder) {
|
||||
if (rows.isNotEmpty) {
|
||||
rows.add(const SizedBox(height: 2.0));
|
||||
}
|
||||
rows.add(_buildDiscoveryLoadingPlaceholder(rowHeight));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +796,29 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
default:
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final folderId = rowId.substring('discovery:'.length);
|
||||
// Check per-row preference
|
||||
final isEnabled = _discoveryRowEnabled[folderId] ?? false;
|
||||
if (!isEnabled) return null;
|
||||
|
||||
try {
|
||||
final folder = _discoveryFolders.firstWhere(
|
||||
(f) => f.itemId == folderId,
|
||||
);
|
||||
return DiscoveryRow(
|
||||
key: ValueKey(rowId),
|
||||
title: folder.name,
|
||||
loadItems: () => provider.getDiscoveryFolderItems(folderId),
|
||||
getCachedItems: () => provider.getCachedDiscoveryFolderItems(folderId),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
} catch (e) {
|
||||
// Folder not found, skip this row
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +58,15 @@ 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();
|
||||
}
|
||||
});
|
||||
if (mounted) {
|
||||
markDetailScreenEntered(context);
|
||||
_extractColors(); // Extract colors immediately - async so won't block
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,158 +556,135 @@ 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,
|
||||
),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Text(S.of(context)!.noPlayersAvailable),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: players.length,
|
||||
itemBuilder: (context, playerIndex) {
|
||||
final player = players[playerIndex];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.speaker,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
_logger.log('Adding playlist to queue on ${player.name}');
|
||||
await maProvider.playTracks(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: 0,
|
||||
clearQueue: false,
|
||||
);
|
||||
_logger.log('Playlist added to queue on ${player.name}');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.tracksAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding playlist to queue: $e');
|
||||
_showError('Failed to add playlist to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
),
|
||||
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: () async {
|
||||
Navigator.pop(context);
|
||||
// Add to queue on selected player
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player != null) {
|
||||
try {
|
||||
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.tracksAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
|
||||
}
|
||||
}
|
||||
} 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
void _addPlaylistToQueue() {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.addToQueueOn,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
_logger.log('Adding playlist to queue on ${player.name}');
|
||||
await maProvider.addTracksToQueue(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
);
|
||||
_logger.log('Playlist added to queue on ${player.name}');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.tracksAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding playlist to queue: $e');
|
||||
_showError('Failed to add playlist to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
await maProvider.playTracks(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: index,
|
||||
clearQueue: false,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.tracksAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding to queue: $e');
|
||||
_showError('Failed to add to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.addToQueueOn,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
await maProvider.addTracksToQueue(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: index,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.tracksAddedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding to queue: $e');
|
||||
_showError('Failed to add to queue: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
||||
@@ -741,7 +887,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -782,7 +928,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
children: [
|
||||
// Main Play Button
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
@@ -802,42 +947,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 +960,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,168 +1040,108 @@ 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(
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Track number
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.track,
|
||||
item: track,
|
||||
isFavorite: track.favorite ?? false,
|
||||
isInLibrary: track.inLibrary,
|
||||
onToggleFavorite: () => _toggleTrackFavorite(index),
|
||||
onToggleLibrary: () => _toggleTrackLibrary(index),
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Track number
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Track artwork
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: trackImageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: trackImageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.music_note,
|
||||
size: 24,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.music_note,
|
||||
size: 24,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (HiResBadge.getTooltip(track) != null) ...[
|
||||
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (track.duration != null)
|
||||
SizedBox(
|
||||
width: 28,
|
||||
width: 40,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
_formatDuration(track.duration!),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Track artwork
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: trackImageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: trackImageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.music_note,
|
||||
size: 24,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.music_note,
|
||||
size: 24,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(
|
||||
track.name,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistsString,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: track.duration != null
|
||||
? Text(
|
||||
_formatDuration(track.duration!),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
if (isExpanded) {
|
||||
setState(() {
|
||||
_expandedTrackIndex = null;
|
||||
});
|
||||
} else {
|
||||
_playTrack(index);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
setState(() {
|
||||
_expandedTrackIndex = isExpanded ? null : index;
|
||||
});
|
||||
},
|
||||
],
|
||||
),
|
||||
// Expandable action buttons
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: isExpanded
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0, bottom: 12.0, top: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Radio button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showPlayRadioMenu(context, index),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.radio, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Add to queue button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _addTrackToQueue(context, index),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.playlist_add, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Favorite button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _toggleTrackFavorite(index),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
track.favorite == true ? Icons.favorite : Icons.favorite_border,
|
||||
size: 20,
|
||||
color: track.favorite == true
|
||||
? colorScheme.error
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
onTap: () => _playTrack(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: _tracks.length,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
if (mounted) {
|
||||
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
|
||||
@@ -537,7 +736,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -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,190 +978,126 @@ 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(
|
||||
onTap: () => _playEpisode(episode),
|
||||
onLongPress: () {
|
||||
setState(() {
|
||||
_expandedEpisodeId = isExpanded ? null : episodeId;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Episode cover (bigger than before)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: episodeImageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: episodeImageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
return GestureDetector(
|
||||
onTap: () => _playEpisode(episode),
|
||||
onLongPressStart: (details) => _showEpisodeContextMenu(
|
||||
context,
|
||||
details.globalPosition,
|
||||
episode,
|
||||
episodeImageUrl,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Episode cover
|
||||
Hero(
|
||||
tag: HeroTags.podcastEpisodeCover + (episode.uri ?? episode.itemId),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: episodeImageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: episodeImageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Episode info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
episode.name,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Episode info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
episode.name,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
episodeDescription,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
// Duration and release date row
|
||||
Row(
|
||||
children: [
|
||||
if (duration != null && duration > Duration.zero) ...[
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
episodeDescription,
|
||||
maxLines: isExpanded ? 100 : 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
_formatDuration(duration),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
// Duration and release date row
|
||||
Row(
|
||||
children: [
|
||||
if (duration != null && duration > Duration.zero) ...[
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
if (duration != null && duration > Duration.zero && releaseDate != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'•',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (duration != null && duration > Duration.zero && releaseDate != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'•',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (releaseDate != null)
|
||||
Text(
|
||||
releaseDate,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (releaseDate != null)
|
||||
Text(
|
||||
releaseDate,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Expanded action buttons (matching album track style)
|
||||
AnimatedCrossFade(
|
||||
firstChild: const SizedBox.shrink(),
|
||||
secondChild: Padding(
|
||||
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Play button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _playEpisode(episode),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Play on... button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showPlayOnMenu(context, episode),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.speaker_group_outlined, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Add to queue button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _addToQueue(episode),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.playlist_add, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
crossFadeState: isExpanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: _episodes.length,
|
||||
|
||||
@@ -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(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'");
|
||||
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.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Episode title
|
||||
Text(
|
||||
widget.episode.name,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Spacing.vGap8,
|
||||
|
||||
// Podcast name (tappable to go back)
|
||||
InkWell(
|
||||
onTap: () => Navigator.pop(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(
|
||||
widget.podcast.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Spacing.vGap16,
|
||||
|
||||
// Duration and publish date
|
||||
Row(
|
||||
children: [
|
||||
if (duration != null && duration > Duration.zero) ...[
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (duration != null && duration > Duration.zero && publishDate != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'•',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (publishDate != null)
|
||||
Text(
|
||||
publishDate,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons row
|
||||
Row(
|
||||
children: [
|
||||
// Main Play Button
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _playEpisode,
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(S.of(context)!.play),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Play On... Button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _showPlayOnMenu,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.speaker_group_outlined, size: 25),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Add to Queue Button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _addToQueue,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.playlist_add, size: 25),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Full scrollable description (key feature)
|
||||
if (description != null && description.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 0, 24.0, 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.about,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Spacing.vGap12,
|
||||
// Full description - no maxLines, fully scrollable with selectable text
|
||||
SelectableText(
|
||||
_stripHtml(description),
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.8),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom spacing for mini player
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../providers/locale_provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
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';
|
||||
@@ -21,6 +26,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _lastFmApiKeyController = TextEditingController();
|
||||
final _audioDbApiKeyController = TextEditingController();
|
||||
String _appVersion = '';
|
||||
// Main rows (default on)
|
||||
bool _showRecentAlbums = true;
|
||||
bool _showDiscoverArtists = true;
|
||||
@@ -39,19 +45,77 @@ 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;
|
||||
// Display settings
|
||||
bool _showProviderIcons = true;
|
||||
// Discovery folders state
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
Map<String, bool> _discoveryRowEnabled = {};
|
||||
bool _isLoadingDiscoveryFolders = false;
|
||||
|
||||
/// All available static home screen rows (that can be toggled on/off)
|
||||
static const List<String> _allAvailableRows = [
|
||||
'recent-albums',
|
||||
'discover-artists',
|
||||
'discover-albums',
|
||||
'discovery-mixes',
|
||||
'continue-listening',
|
||||
'discover-audiobooks',
|
||||
'discover-series',
|
||||
'favorite-albums',
|
||||
'favorite-artists',
|
||||
'favorite-tracks',
|
||||
'favorite-playlists',
|
||||
'favorite-radio-stations',
|
||||
'favorite-podcasts',
|
||||
];
|
||||
|
||||
/// Filtered row order for the draggable list
|
||||
/// Shows all available rows (in home order + discovery rows), regardless of enabled state
|
||||
List<String> get _filteredRowOrder {
|
||||
final rows = <String>[];
|
||||
|
||||
// First, add all static rows in their current home order
|
||||
for (final rowId in _homeRowOrder) {
|
||||
if (rowId == 'discovery-mixes') continue;
|
||||
rows.add(rowId);
|
||||
}
|
||||
|
||||
// Then, add any static rows that aren't in home order yet (so users can enable them)
|
||||
for (final rowId in _allAvailableRows) {
|
||||
if (!_homeRowOrder.contains(rowId) && rowId != 'discovery-mixes') {
|
||||
rows.add(rowId);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, add any discovery rows from provider that aren't in home order yet
|
||||
for (final folder in _discoveryFolders) {
|
||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||
if (!_homeRowOrder.contains(discoveryRowId)) {
|
||||
rows.add(discoveryRowId);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
_loadAppVersion();
|
||||
}
|
||||
|
||||
Future<void> _loadAppVersion() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = '${info.version}+${info.buildNumber}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
@@ -85,9 +149,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
|
||||
@@ -96,6 +158,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load display settings
|
||||
final showProviderIcons = await SettingsService.getShowProviderIcons();
|
||||
|
||||
// Load discovery row preferences
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -111,14 +176,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_disableAutoSwitch = disableAutoSwitch;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showProviderIcons = showProviderIcons;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
});
|
||||
}
|
||||
|
||||
// Load discovery folders after initial state is set
|
||||
_loadDiscoveryFolders();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -128,9 +195,84 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Load discovery folders from Music Assistant
|
||||
Future<void> _loadDiscoveryFolders() async {
|
||||
if (_discoveryFolders.isEmpty && mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = true;
|
||||
});
|
||||
}
|
||||
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
try {
|
||||
final folders = await provider.getDiscoveryFoldersWithCache();
|
||||
// Also reload discovery row preferences to ensure they're up to date
|
||||
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryFolders = folders;
|
||||
_discoveryRowEnabled = discoveryRowPrefs;
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh discovery folders from Music Assistant
|
||||
Future<void> _refreshDiscoveryFolders() async {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
try {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = true;
|
||||
});
|
||||
await provider.refreshDiscoveryFolders();
|
||||
final folders = provider.getCachedDiscoveryFolders() ?? [];
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_discoveryFolders = folders;
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingDiscoveryFolders = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a specific discovery row on/off
|
||||
Future<void> _toggleDiscoveryRow(String itemId, bool enabled) async {
|
||||
setState(() {
|
||||
_discoveryRowEnabled[itemId] = enabled;
|
||||
});
|
||||
await SettingsService.setDiscoveryRowPreference(itemId, enabled);
|
||||
}
|
||||
|
||||
// Helper to get row display info
|
||||
Map<String, String> _getRowInfo(String rowId) {
|
||||
final s = S.of(context)!;
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
final folder = _discoveryFolders.firstWhere(
|
||||
(f) => f.itemId == itemId,
|
||||
orElse: () => RecommendationFolder(
|
||||
itemId: itemId,
|
||||
provider: 'unknown',
|
||||
name: rowId,
|
||||
items: [],
|
||||
),
|
||||
);
|
||||
return {'title': folder.name, 'subtitle': 'Discovery row from Music Assistant'};
|
||||
}
|
||||
switch (rowId) {
|
||||
case 'recent-albums':
|
||||
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
|
||||
@@ -138,6 +280,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
|
||||
case 'discover-albums':
|
||||
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
|
||||
case 'discovery-mixes':
|
||||
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription};
|
||||
case 'continue-listening':
|
||||
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
|
||||
case 'discover-audiobooks':
|
||||
@@ -163,6 +307,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
// Helper to get row enabled state
|
||||
bool _getRowEnabled(String rowId) {
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
return _discoveryRowEnabled[itemId] ?? false;
|
||||
}
|
||||
switch (rowId) {
|
||||
case 'recent-albums':
|
||||
return _showRecentAlbums;
|
||||
@@ -196,6 +345,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Helper to set row enabled state
|
||||
void _setRowEnabled(String rowId, bool value) {
|
||||
setState(() {
|
||||
// Handle dynamic discovery folders
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
_discoveryRowEnabled[itemId] = value;
|
||||
SettingsService.setDiscoveryRowPreference(itemId, value);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (rowId) {
|
||||
case 'recent-albums':
|
||||
_showRecentAlbums = value;
|
||||
@@ -267,7 +424,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 +453,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DebugLogScreen(),
|
||||
),
|
||||
FadeSlidePageRoute(child: const DebugLogScreen()),
|
||||
);
|
||||
},
|
||||
color: colorScheme.onBackground,
|
||||
@@ -345,15 +501,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 +859,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 +883,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 +892,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,
|
||||
@@ -903,7 +1007,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Reorderable home rows list
|
||||
// Reorderable home rows list (exclude discovery-mixes from list)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
@@ -914,24 +1018,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _homeRowOrder.length,
|
||||
itemCount: _filteredRowOrder.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
// Create a mutable copy of the filtered list for reordering
|
||||
final displayRows = List<String>.from(_filteredRowOrder);
|
||||
if (newIndex > oldIndex) newIndex--;
|
||||
final item = displayRows.removeAt(oldIndex);
|
||||
displayRows.insert(newIndex, item);
|
||||
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex--;
|
||||
final item = _homeRowOrder.removeAt(oldIndex);
|
||||
_homeRowOrder.insert(newIndex, item);
|
||||
// Update _homeRowOrder with the new order
|
||||
_homeRowOrder.clear();
|
||||
|
||||
// Add discovery rows that are enabled
|
||||
for (final rowId in displayRows) {
|
||||
if (rowId.startsWith('discovery:')) {
|
||||
// Only add enabled discovery rows to home order
|
||||
final itemId = rowId.substring('discovery:'.length);
|
||||
if (_discoveryRowEnabled[itemId] ?? false) {
|
||||
_homeRowOrder.add(rowId);
|
||||
}
|
||||
} else {
|
||||
_homeRowOrder.add(rowId);
|
||||
}
|
||||
}
|
||||
});
|
||||
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final rowId = _homeRowOrder[index];
|
||||
final rowId = _filteredRowOrder[index];
|
||||
final rowInfo = _getRowInfo(rowId);
|
||||
final isEnabled = _getRowEnabled(rowId);
|
||||
|
||||
return Container(
|
||||
key: ValueKey(rowId),
|
||||
decoration: BoxDecoration(
|
||||
border: index < _homeRowOrder.length - 1
|
||||
border: index < _filteredRowOrder.length - 1
|
||||
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
|
||||
: null,
|
||||
),
|
||||
@@ -1062,6 +1184,149 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// About & Support section
|
||||
Text(
|
||||
S.of(context)!.aboutAndSupport,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Support Development banner
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFEA4AAA).withOpacity(0.15),
|
||||
Color(0xFF1F6FEB).withOpacity(0.15),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Color(0xFFEA4AAA),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
S.of(context)!.supportDevelopment,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.ensembleIsUnofficial,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
// GitHub Sponsors button
|
||||
Expanded(
|
||||
child: _DonationButton(
|
||||
label: S.of(context)!.sponsorOnGitHub,
|
||||
color: Color(0xFF1F6FEB),
|
||||
iconWidget: Icon(MdiIcons.github, size: 20),
|
||||
onTap: () => _launchUrl('https://github.com/sponsors/CollotsSpot'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Ko-fi button
|
||||
Expanded(
|
||||
child: _DonationButton(
|
||||
icon: null,
|
||||
label: S.of(context)!.buyMeAKoFi,
|
||||
color: Color(0xFFFF5E5B),
|
||||
iconWidget: Icon(Icons.coffee_rounded, color: Colors.white),
|
||||
onTap: () => _launchUrl('https://ko-fi.com/collotsspot'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// About Ensemble card
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.aboutEnsemble,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.version,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_appVersion,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_LinkButton(
|
||||
icon: Icons.code_rounded,
|
||||
label: S.of(context)!.visitGitHubRepo,
|
||||
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_LinkButton(
|
||||
icon: Icons.bug_report_outlined,
|
||||
label: S.of(context)!.reportABug,
|
||||
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble/issues'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
@@ -1139,4 +1404,227 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the discovery section with individual row toggles
|
||||
Widget _buildDiscoverySection(ColorScheme colorScheme, TextTheme textTheme) {
|
||||
final s = S.of(context)!;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with title and refresh button
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.explore_outlined,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.discoveryRows,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoadingDiscoveryFolders)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: _refreshDiscoveryFolders,
|
||||
tooltip: s.refreshDiscoveryRows,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Discovery rows list
|
||||
if (_discoveryFolders.isEmpty && !_isLoadingDiscoveryFolders)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
s.noDiscoveryRows,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._discoveryFolders.map((folder) {
|
||||
final itemId = folder.itemId;
|
||||
final isEnabled = _discoveryRowEnabled[itemId] ?? false;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
folder.name,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
subtitle: folder.provider.isNotEmpty
|
||||
? Text(
|
||||
folder.provider,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 11,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
value: isEnabled,
|
||||
onChanged: (value) => _toggleDiscoveryRow(itemId, value),
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
|
||||
dense: true,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom donation button widget
|
||||
class _DonationButton extends StatelessWidget {
|
||||
final String? icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final Widget? iconWidget;
|
||||
|
||||
const _DonationButton({
|
||||
this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
this.iconWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (iconWidget != null)
|
||||
iconWidget!
|
||||
else if (icon != null)
|
||||
Image.asset(
|
||||
icon!,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom link button widget
|
||||
class _LinkButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _LinkButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.open_in_new,
|
||||
size: 14,
|
||||
color: colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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),
|
||||
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('Probe status: ${response.statusCode}');
|
||||
|
||||
// Check for redirect (common with reverse proxy auth)
|
||||
if (response.statusCode == 302 || response.statusCode == 307) {
|
||||
final location = response.headers['location'];
|
||||
_logger.log('Redirect to: $location');
|
||||
|
||||
// If redirects to Authelia login, it's Authelia
|
||||
if (location != null && location.contains('authelia')) {
|
||||
_logger.log('✓ Detected Authelia (redirect)');
|
||||
return _strategies.firstWhere((s) => s.name == 'authelia');
|
||||
}
|
||||
}
|
||||
|
||||
// Server returned 401 Unauthorized - check WWW-Authenticate header
|
||||
if (response.statusCode == 401) {
|
||||
final wwwAuth = response.headers['www-authenticate']?.toLowerCase();
|
||||
_logger.log('401 with WWW-Authenticate: $wwwAuth');
|
||||
|
||||
// Check for Basic Auth
|
||||
if (wwwAuth != null && wwwAuth.contains('basic')) {
|
||||
_logger.log('✓ Detected Basic Authentication');
|
||||
return _strategies.firstWhere((s) => s.name == 'basic');
|
||||
}
|
||||
|
||||
// Check for Bearer token - could be MA native auth
|
||||
if (wwwAuth != null && wwwAuth.contains('bearer')) {
|
||||
_logger.log('✓ Detected Bearer auth - likely Music Assistant native');
|
||||
return _strategies.firstWhere((s) => s.name == 'music_assistant');
|
||||
}
|
||||
}
|
||||
|
||||
// If we got here and there's any auth challenge, assume Authelia
|
||||
// (since we're behind a reverse proxy that requires auth)
|
||||
if (response.statusCode >= 300) {
|
||||
_logger.log('Server requires authentication (status ${response.statusCode})');
|
||||
_logger.log('Assuming Authelia (default for authenticated reverse proxy)');
|
||||
return _strategies.firstWhere((s) => s.name == 'authelia');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('✗ Auth detection error: $e');
|
||||
return null; // Return null to trigger fallback
|
||||
}
|
||||
|
||||
_logger.log('⚠️ Could not determine auth method');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if server is a Music Assistant server with native auth
|
||||
/// Returns 'required' if auth needed, 'none' if no auth, null if not MA or error
|
||||
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
|
||||
try {
|
||||
final uri = Uri.parse(baseUrl);
|
||||
// Use the port from URL if specified, otherwise use default ports (80/443)
|
||||
// Don't default to 8095 here - that's only for direct connections without reverse proxy
|
||||
final apiUrl = Uri(
|
||||
scheme: uri.scheme,
|
||||
host: uri.host,
|
||||
port: uri.hasPort ? uri.port : null,
|
||||
path: '/api',
|
||||
);
|
||||
|
||||
_logger.log('Checking MA API at: $apiUrl');
|
||||
|
||||
// Use IOClient to control redirect behavior for local IPs
|
||||
final isLocalIp = _isLocalIpAddress(uri.host);
|
||||
http.Response response;
|
||||
|
||||
if (isLocalIp) {
|
||||
// For local IPs, don't follow redirects - handle them explicitly
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final request = await client.postUrl(apiUrl);
|
||||
request.followRedirects = false;
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
request.write(jsonEncode({'command': 'info'}));
|
||||
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
|
||||
|
||||
// Check for redirect to HTTPS
|
||||
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
|
||||
final location = ioResponse.headers.value('location');
|
||||
_logger.log('Local IP redirects to: $location');
|
||||
if (location != null && location.startsWith('https://')) {
|
||||
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
|
||||
// Don't follow the redirect - it will likely fail with cert error
|
||||
// Instead, return null to trigger fallback logic
|
||||
client.close();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final body = await ioResponse.transform(utf8.decoder).join();
|
||||
response = http.Response(body, ioResponse.statusCode);
|
||||
client.close();
|
||||
} catch (e) {
|
||||
client.close();
|
||||
rethrow;
|
||||
}
|
||||
} else {
|
||||
// For non-local URLs, use standard http.post which follows redirects
|
||||
response = await http.post(
|
||||
apiUrl,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'command': 'info',
|
||||
}),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
_logger.log('MA API response: ${response.statusCode}');
|
||||
_logger.log('MA API body: ${response.body}');
|
||||
|
||||
// 401/403 means auth required - check this first
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
_logger.log('MA API returned ${response.statusCode} - auth required');
|
||||
return 'required';
|
||||
}
|
||||
|
||||
// Also check for "Authentication required" text response
|
||||
if (response.body.toLowerCase().contains('authentication required')) {
|
||||
_logger.log('MA API returned auth required message');
|
||||
return 'required';
|
||||
}
|
||||
_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>;
|
||||
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');
|
||||
if (data.containsKey('error_code')) {
|
||||
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final result = data['result'] as Map<String, dynamic>?;
|
||||
_accessToken = result?['access_token'] as String?;
|
||||
|
||||
if (_accessToken != null) {
|
||||
_logger.log('✓ Got access token from MA');
|
||||
|
||||
// Try to create a long-lived token
|
||||
_longLivedToken = await _createLongLivedToken(apiUrl, _accessToken!);
|
||||
|
||||
return _longLivedToken ?? _accessToken;
|
||||
}
|
||||
|
||||
_logger.log('✗ No access token in response');
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.log('✗ Authentication failed: ${response.statusCode}');
|
||||
return null;
|
||||
} catch (e) {
|
||||
_logger.log('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();
|
||||
} catch (e) {
|
||||
client.close();
|
||||
rethrow;
|
||||
}
|
||||
} else {
|
||||
// For non-local URLs, use standard http.get
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 10));
|
||||
statusCode = response.statusCode;
|
||||
}
|
||||
|
||||
_logger.log('No-auth status: $statusCode');
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
// 200 = no auth required
|
||||
// 302/307/308 = redirect (probably to auth or HTTPS)
|
||||
// 401 = auth required
|
||||
if (statusCode == 200) {
|
||||
return true;
|
||||
/// Validate a stored token
|
||||
Future<bool> validateToken(String serverUrl, String storedToken) async {
|
||||
try {
|
||||
final apiUrl = _buildApiUrl(serverUrl);
|
||||
|
||||
final response = await http.post(
|
||||
apiUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $storedToken',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'command': 'server/info',
|
||||
}),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
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) {
|
||||
return {};
|
||||
if (token != null) {
|
||||
return {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
return _currentStrategy!.buildWebSocketHeaders(_currentCredentials!);
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Get HTTP streaming headers for current credentials
|
||||
/// Build headers for HTTP streaming requests
|
||||
Map<String, String> getStreamingHeaders() {
|
||||
if (_currentStrategy == null || _currentCredentials == null) {
|
||||
return {};
|
||||
if (token != null) {
|
||||
return {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
return _currentStrategy!.buildStreamingHeaders(_currentCredentials!);
|
||||
return {};
|
||||
}
|
||||
|
||||
/// 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!),
|
||||
};
|
||||
/// Set token from external source (e.g., restored from settings)
|
||||
void setToken(String? storedToken) {
|
||||
_longLivedToken = storedToken;
|
||||
}
|
||||
|
||||
/// 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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', {});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
@@ -10,6 +11,7 @@ import '../constants/timings.dart';
|
||||
class CacheService {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
bool _homeRowsLoaded = false;
|
||||
Future<void>? _homeRowsLoadFuture;
|
||||
|
||||
// Cache size limits to prevent unbounded memory growth
|
||||
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
|
||||
@@ -20,12 +22,14 @@ class CacheService {
|
||||
List<Album>? _cachedRecentAlbums;
|
||||
List<Artist>? _cachedDiscoverArtists;
|
||||
List<Album>? _cachedDiscoverAlbums;
|
||||
List<RecommendationFolder>? _cachedDiscoveryFolders;
|
||||
List<Audiobook>? _cachedInProgressAudiobooks;
|
||||
List<Audiobook>? _cachedDiscoverAudiobooks;
|
||||
List<AudiobookSeries>? _cachedDiscoverSeries;
|
||||
DateTime? _recentAlbumsLastFetched;
|
||||
DateTime? _discoverArtistsLastFetched;
|
||||
DateTime? _discoverAlbumsLastFetched;
|
||||
DateTime? _discoveryFoldersLastFetched;
|
||||
DateTime? _inProgressAudiobooksLastFetched;
|
||||
DateTime? _discoverAudiobooksLastFetched;
|
||||
DateTime? _discoverSeriesLastFetched;
|
||||
@@ -55,6 +59,14 @@ class CacheService {
|
||||
// HOME SCREEN ROW CACHING
|
||||
// ============================================================================
|
||||
|
||||
/// Ensure home rows are loaded from database before accessing cache
|
||||
/// This ensures the cache is populated on first access if not already loaded
|
||||
Future<void> ensureHomeRowsLoaded() async {
|
||||
if (!_homeRowsLoaded) {
|
||||
await loadHomeRowsFromDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if recent albums cache is valid
|
||||
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -69,6 +81,11 @@ class CacheService {
|
||||
|
||||
/// Set cached recent albums
|
||||
void setCachedRecentAlbums(List<Album> albums) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (albums.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty recent albums result');
|
||||
return;
|
||||
}
|
||||
_cachedRecentAlbums = albums;
|
||||
_recentAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} recent albums');
|
||||
@@ -90,6 +107,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover artists
|
||||
void setCachedDiscoverArtists(List<Artist> artists) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (artists.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover artists result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverArtists = artists;
|
||||
_discoverArtistsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${artists.length} discover artists');
|
||||
@@ -111,6 +133,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover albums
|
||||
void setCachedDiscoverAlbums(List<Album> albums) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (albums.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover albums result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverAlbums = albums;
|
||||
_discoverAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} discover albums');
|
||||
@@ -118,6 +145,32 @@ class CacheService {
|
||||
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if discovery folders cache is valid
|
||||
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final now = DateTime.now();
|
||||
return _cachedDiscoveryFolders != null &&
|
||||
_discoveryFoldersLastFetched != null &&
|
||||
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
|
||||
}
|
||||
|
||||
/// Get cached discovery folders
|
||||
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
|
||||
|
||||
/// Set cached discovery folders
|
||||
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) {
|
||||
// Don't cache empty results (prevents persisting "not authenticated" errors)
|
||||
if (folders.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discovery folders result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoveryFolders = folders;
|
||||
_discoveryFoldersLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${folders.length} discovery folders');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if in-progress audiobooks cache is valid
|
||||
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -132,6 +185,11 @@ class CacheService {
|
||||
|
||||
/// Set cached in-progress audiobooks
|
||||
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
|
||||
// Don't cache empty results
|
||||
if (audiobooks.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty in-progress audiobooks result');
|
||||
return;
|
||||
}
|
||||
_cachedInProgressAudiobooks = audiobooks;
|
||||
_inProgressAudiobooksLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
|
||||
@@ -151,6 +209,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover audiobooks
|
||||
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
|
||||
// Don't cache empty results
|
||||
if (audiobooks.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover audiobooks result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverAudiobooks = audiobooks;
|
||||
_discoverAudiobooksLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
|
||||
@@ -170,6 +233,11 @@ class CacheService {
|
||||
|
||||
/// Set cached discover series
|
||||
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
|
||||
// Don't cache empty results
|
||||
if (series.isEmpty) {
|
||||
_logger.log('⚠️ Not caching empty discover series result');
|
||||
return;
|
||||
}
|
||||
_cachedDiscoverSeries = series;
|
||||
_discoverSeriesLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${series.length} discover series');
|
||||
@@ -191,6 +259,7 @@ class CacheService {
|
||||
_recentAlbumsLastFetched = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_discoveryFoldersLastFetched = null;
|
||||
_inProgressAudiobooksLastFetched = null;
|
||||
_discoverAudiobooksLastFetched = null;
|
||||
_discoverSeriesLastFetched = null;
|
||||
@@ -448,10 +517,22 @@ class CacheService {
|
||||
// ============================================================================
|
||||
|
||||
/// Load home row data from database for instant display on startup
|
||||
/// Returns the existing future if already loading, to prevent duplicate loads
|
||||
Future<void> loadHomeRowsFromDatabase() async {
|
||||
if (_homeRowsLoadFuture != null) {
|
||||
// Already loading, return the existing future
|
||||
return _homeRowsLoadFuture!;
|
||||
}
|
||||
if (_homeRowsLoaded) return;
|
||||
_homeRowsLoaded = true;
|
||||
|
||||
// Create the load future
|
||||
_homeRowsLoadFuture = _doLoadHomeRowsFromDatabase();
|
||||
await _homeRowsLoadFuture;
|
||||
_homeRowsLoadFuture = null; // Clear after completion
|
||||
}
|
||||
|
||||
/// Internal implementation of home row loading
|
||||
Future<void> _doLoadHomeRowsFromDatabase() async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
|
||||
@@ -499,8 +580,25 @@ class CacheService {
|
||||
_logger.log('⚠️ Failed to parse discover albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load discovery folders
|
||||
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
|
||||
if (foldersData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(foldersData.itemsJson) as List)
|
||||
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedDiscoveryFolders = items;
|
||||
_discoveryFoldersLastFetched = foldersData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} discovery folders from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse discovery folders: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading home rows from database: $e');
|
||||
} finally {
|
||||
_homeRowsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import '../models/media_item.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
|
||||
/// Service for handling image/artwork URL generation and caching.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to reduce complexity.
|
||||
class ImageHelperService {
|
||||
final MusicAssistantAPI? _api;
|
||||
|
||||
/// Podcast cover cache: podcastId -> best available cover URL
|
||||
/// This is populated with episode covers when podcast covers are low-res
|
||||
final Map<String, String> _podcastCoverCache = {};
|
||||
|
||||
/// Callback to get cached track for a player
|
||||
final Track? Function(String playerId) getCachedTrackForPlayer;
|
||||
|
||||
ImageHelperService({
|
||||
required MusicAssistantAPI? api,
|
||||
required this.getCachedTrackForPlayer,
|
||||
}) : _api = api;
|
||||
|
||||
/// Get artwork URL for a media item from Music Assistant
|
||||
String? getImageUrl(MediaItem item, {int size = 256}) {
|
||||
return _api?.getImageUrl(item, size: size);
|
||||
}
|
||||
|
||||
/// Get best available podcast cover URL
|
||||
///
|
||||
/// Returns cached iTunes URL (800x800) if available, otherwise falls back to MA imageproxy.
|
||||
/// The cache is persisted to storage and loaded on app start for instant high-res display.
|
||||
String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) {
|
||||
// Return cached iTunes URL if available (persisted across app launches)
|
||||
final cachedUrl = _podcastCoverCache[podcast.itemId];
|
||||
if (cachedUrl != null) {
|
||||
return cachedUrl;
|
||||
}
|
||||
// Fall back to MA imageproxy (will be replaced once iTunes fetch completes)
|
||||
return _api?.getImageUrl(podcast, size: size);
|
||||
}
|
||||
|
||||
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
|
||||
///
|
||||
/// Returns a Future since fallback requires async API calls.
|
||||
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
|
||||
// Try Music Assistant first
|
||||
final maUrl = _api?.getImageUrl(artist, size: size);
|
||||
if (maUrl != null) {
|
||||
return maUrl;
|
||||
}
|
||||
|
||||
// Fall back to external sources (Deezer, Fanart.tv, etc.)
|
||||
return MetadataService.getArtistImageUrl(artist.name);
|
||||
}
|
||||
|
||||
/// Get SVG icon for a provider domain
|
||||
///
|
||||
/// Returns null if no icon is available.
|
||||
String? getProviderIconSvg(String domain, {bool isDark = false}) {
|
||||
return _api?.getProviderIconSvg(domain, isDark: isDark);
|
||||
}
|
||||
|
||||
/// Get provider manifest by domain
|
||||
ProviderManifest? getProviderManifest(String domain) {
|
||||
return _api?.getProviderManifest(domain);
|
||||
}
|
||||
|
||||
/// Get artwork URL for a player from cache
|
||||
String? getCachedArtworkUrl(String playerId, {int size = 512}) {
|
||||
final track = getCachedTrackForPlayer(playerId);
|
||||
if (track == null) return null;
|
||||
return getImageUrl(track, size: size);
|
||||
}
|
||||
|
||||
/// Load podcast cover cache from storage for instant high-res display
|
||||
///
|
||||
/// Should be called during initialization.
|
||||
Future<void> loadPodcastCoverCache(Map<String, String> cache) async {
|
||||
if (cache.isNotEmpty) {
|
||||
_podcastCoverCache.addAll(cache);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current podcast cover cache (for persistence)
|
||||
Map<String, String> get podcastCoverCache => Map.unmodifiable(_podcastCoverCache);
|
||||
|
||||
/// Update podcast cover cache entry
|
||||
void updatePodcastCoverCache(String podcastId, String coverUrl) {
|
||||
_podcastCoverCache[podcastId] = coverUrl;
|
||||
}
|
||||
|
||||
/// Clear all podcast cover cache entries
|
||||
void clearPodcastCoverCache() {
|
||||
_podcastCoverCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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();
|
||||
}
|
||||
|
||||
/// Initialize library status without notifying listeners.
|
||||
/// Use during initState to sync service from widget data without triggering rebuilds.
|
||||
void initLibraryStatus(String key, bool inLibrary) {
|
||||
_libraryStatus[key] ??= inLibrary;
|
||||
}
|
||||
|
||||
/// Initialize favorite status without notifying listeners.
|
||||
/// Use during initState to sync service from widget data without triggering rebuilds.
|
||||
void initFavoriteStatus(String key, bool favorite) {
|
||||
_favoriteStatus[key] ??= favorite;
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../models/provider_manifest.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'device_id_service.dart';
|
||||
@@ -214,6 +215,7 @@ class MusicAssistantAPI {
|
||||
},
|
||||
onDone: () {
|
||||
_logger.log('WebSocket connection closed');
|
||||
_isAuthenticated = false; // Reset auth state on disconnect
|
||||
_updateConnectionState(MAConnectionState.disconnected);
|
||||
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||
_connectionCompleter!.completeError(Exception('Connection closed'));
|
||||
@@ -1194,6 +1196,28 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ RECOMMENDATIONS ============
|
||||
|
||||
/// Get recommendation folders (mixes, recommendations, etc.)
|
||||
Future<List<RecommendationFolder>> getRecommendations() async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/recommendations',
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
|
||||
return items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(RecommendationFolder.fromJson)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting recommendations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the audiobookshelf provider instance ID
|
||||
Future<String?> getAudiobookshelfProviderId() async {
|
||||
try {
|
||||
@@ -2076,6 +2100,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 {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
import '../services/sync_service.dart';
|
||||
|
||||
/// Service for filtering media items and players based on user settings.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to reduce complexity.
|
||||
class ProviderFilterService {
|
||||
/// Provider filter from MA user settings (empty = all providers allowed)
|
||||
final List<String> providerFilter;
|
||||
|
||||
/// Player filter from MA user settings (empty = all players allowed)
|
||||
final List<String> playerFilter;
|
||||
|
||||
/// Available music providers discovered from MA
|
||||
final List<ProviderInstance> availableMusicProviders;
|
||||
|
||||
/// Library data for provider counting
|
||||
final List<dynamic> artists;
|
||||
final List<dynamic> albums;
|
||||
final List<dynamic> tracks;
|
||||
final List<dynamic> podcasts;
|
||||
final List<dynamic> radioStations;
|
||||
|
||||
ProviderFilterService({
|
||||
required this.providerFilter,
|
||||
required this.playerFilter,
|
||||
required this.availableMusicProviders,
|
||||
required this.artists,
|
||||
required this.albums,
|
||||
required this.tracks,
|
||||
required this.podcasts,
|
||||
required this.radioStations,
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// PROVIDER FILTER (from MA user settings)
|
||||
// ===========================================================================
|
||||
|
||||
/// Whether provider filtering is active
|
||||
bool get hasProviderFilter => providerFilter.isNotEmpty;
|
||||
|
||||
/// Check if a media item should be visible based on provider filter.
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - No filter is active (empty list = all providers allowed)
|
||||
/// - The item has at least one provider mapping in the allowed list
|
||||
/// - The item's primary provider is in the allowed list
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
// No filter = show everything
|
||||
if (providerFilter.isEmpty) return true;
|
||||
|
||||
// Check if item's provider mappings include any allowed provider
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null && mappings.isNotEmpty) {
|
||||
for (final mapping in mappings) {
|
||||
if (providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check primary provider field (for items without full mappings)
|
||||
if (providerFilter.contains(item.provider)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Filter a list of media items based on provider filter.
|
||||
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
||||
if (providerFilter.isEmpty) return items;
|
||||
return items.where(isItemAllowedByProviderFilter).toList();
|
||||
}
|
||||
|
||||
/// Filter search results map based on provider filter.
|
||||
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
||||
if (providerFilter.isEmpty) return results;
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PLAYER FILTER (from MA user settings)
|
||||
// ===========================================================================
|
||||
|
||||
/// Whether player filtering is active
|
||||
bool get hasPlayerFilter => playerFilter.isNotEmpty;
|
||||
|
||||
/// Check if a player should be visible based on player filter.
|
||||
bool isPlayerAllowedByFilter(Player player) {
|
||||
if (playerFilter.isEmpty) return true;
|
||||
return playerFilter.contains(player.playerId);
|
||||
}
|
||||
|
||||
/// Filter a list of players based on player filter.
|
||||
List<Player> filterPlayers(List<Player> players) {
|
||||
if (playerFilter.isEmpty) return players;
|
||||
return players.where(isPlayerAllowedByFilter).toList();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PROVIDER COUNTING
|
||||
// ===========================================================================
|
||||
|
||||
/// Get providers that have content in artists library (with item counts).
|
||||
Map<String, int> getProvidersWithArtists() {
|
||||
return _getProvidersWithCounts(artists);
|
||||
}
|
||||
|
||||
/// Get providers that have content in albums library (with item counts).
|
||||
Map<String, int> getProvidersWithAlbums() {
|
||||
return _getProvidersWithCounts(albums);
|
||||
}
|
||||
|
||||
/// Get providers that have content in tracks library (with item counts).
|
||||
Map<String, int> getProvidersWithTracks() {
|
||||
return _getProvidersWithCounts(tracks);
|
||||
}
|
||||
|
||||
/// Get providers that have content in playlists (with item counts).
|
||||
Map<String, int> getProvidersWithPlaylists() {
|
||||
return _getProvidersWithCounts(SyncService.instance.cachedPlaylists);
|
||||
}
|
||||
|
||||
/// Get providers that have audiobooks (with item counts).
|
||||
Map<String, int> getProvidersWithAudiobooks() {
|
||||
return _getProvidersWithCounts(SyncService.instance.cachedAudiobooks);
|
||||
}
|
||||
|
||||
/// Get providers that have radio stations (with item counts).
|
||||
Map<String, int> getProvidersWithRadio() {
|
||||
return _getProvidersWithCounts(radioStations);
|
||||
}
|
||||
|
||||
/// Get providers that have podcasts (with item counts).
|
||||
///
|
||||
/// Only counts mappings where inLibrary is true - this indicates which provider
|
||||
/// the user actually added the podcast from (vs providers that can play it).
|
||||
Map<String, int> getProvidersWithPodcasts() {
|
||||
final counts = <String, int>{};
|
||||
for (final item in podcasts) {
|
||||
final mappings = (item as MediaItem).providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item (user added it from this account)
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Internal helper to count items per provider from a list of media items.
|
||||
///
|
||||
/// Only counts mappings where inLibrary is true - this indicates which provider
|
||||
/// the user actually added the item from (vs providers that can play it).
|
||||
Map<String, int> _getProvidersWithCounts(List<dynamic> items) {
|
||||
final counts = <String, int>{};
|
||||
for (final item in items) {
|
||||
if (item is! MediaItem) continue;
|
||||
final mediaItem = item as MediaItem;
|
||||
final mappings = mediaItem.providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item (user added it from this account)
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Get ProviderInstance objects for providers that have content in a category.
|
||||
///
|
||||
/// Returns list of (ProviderInstance, itemCount) tuples, sorted by name.
|
||||
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
|
||||
final Map<String, int> counts;
|
||||
switch (category) {
|
||||
case 'artists':
|
||||
counts = getProvidersWithArtists();
|
||||
break;
|
||||
case 'albums':
|
||||
counts = getProvidersWithAlbums();
|
||||
break;
|
||||
case 'tracks':
|
||||
counts = getProvidersWithTracks();
|
||||
break;
|
||||
case 'playlists':
|
||||
counts = getProvidersWithPlaylists();
|
||||
break;
|
||||
case 'audiobooks':
|
||||
counts = getProvidersWithAudiobooks();
|
||||
break;
|
||||
case 'radio':
|
||||
counts = getProvidersWithRadio();
|
||||
break;
|
||||
case 'podcasts':
|
||||
counts = getProvidersWithPodcasts();
|
||||
break;
|
||||
default:
|
||||
counts = {};
|
||||
}
|
||||
|
||||
// Build list of providers that support this category, with their item counts
|
||||
// Only include providers that have at least 1 item (hides providers not synced to library)
|
||||
// Multiple instances of the same domain are allowed (e.g., two Spotify accounts)
|
||||
// but we dedupe if names match exactly (same account re-added with new instance ID)
|
||||
final result = <(ProviderInstance, int)>[];
|
||||
final addedInstanceIds = <String>{};
|
||||
final addedNames = <String, int>{}; // Track name -> count to dedupe same-name providers
|
||||
|
||||
for (final provider in availableMusicProviders) {
|
||||
// Only include providers that support this content type AND have items
|
||||
if (provider.supportsContentType(category)) {
|
||||
final count = counts[provider.instanceId] ?? 0;
|
||||
if (count > 0) {
|
||||
final existingCount = addedNames[provider.name];
|
||||
if (existingCount == null) {
|
||||
// First provider with this name
|
||||
result.add((provider, count));
|
||||
addedInstanceIds.add(provider.instanceId);
|
||||
addedNames[provider.name] = count;
|
||||
} else if (count > existingCount) {
|
||||
// Same name but more items - likely same account re-added, keep better one
|
||||
result.removeWhere((r) => r.$1.name == provider.name);
|
||||
result.add((provider, count));
|
||||
addedInstanceIds.add(provider.instanceId);
|
||||
addedNames[provider.name] = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also include providers that have items but aren't in availableMusicProviders
|
||||
// (e.g., search-only providers like iTunes, or orphaned items from removed providers)
|
||||
// For these synthetic providers, dedupe by domain since we don't have account-specific names
|
||||
final addedRealDomains = result.map((r) => r.$1.domain).toSet();
|
||||
final addedSyntheticDomains = <String>{};
|
||||
|
||||
for (final entry in counts.entries) {
|
||||
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
|
||||
// Create a synthetic ProviderInstance from the instance ID
|
||||
// Format is typically "domain--uniqueId" (e.g., "itunes--abc123")
|
||||
final instanceId = entry.key;
|
||||
final domain = instanceId.contains('--')
|
||||
? instanceId.split('--').first
|
||||
: instanceId;
|
||||
|
||||
// Skip if this domain is already represented by a real provider or synthetic
|
||||
if (addedRealDomains.contains(domain)) continue;
|
||||
if (addedSyntheticDomains.contains(domain)) continue;
|
||||
|
||||
// Only add if this domain supports the category
|
||||
final capabilities = ProviderInstance.providerCapabilities[domain];
|
||||
if (capabilities != null && capabilities.contains(category)) {
|
||||
result.add((
|
||||
ProviderInstance(
|
||||
instanceId: instanceId,
|
||||
domain: domain,
|
||||
name: _formatProviderName(domain),
|
||||
available: true,
|
||||
),
|
||||
entry.value,
|
||||
));
|
||||
addedSyntheticDomains.add(domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name
|
||||
result.sort((a, b) => a.$1.name.compareTo(b.$1.name));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Format a provider domain into a readable display name.
|
||||
String _formatProviderName(String domain) {
|
||||
const names = {
|
||||
'spotify': 'Spotify',
|
||||
'tidal': 'Tidal',
|
||||
'qobuz': 'Qobuz',
|
||||
'deezer': 'Deezer',
|
||||
'ytmusic': 'YouTube Music',
|
||||
'soundcloud': 'SoundCloud',
|
||||
'apple_music': 'Apple Music',
|
||||
'amazon_music': 'Amazon Music',
|
||||
'plex': 'Plex',
|
||||
'jellyfin': 'Jellyfin',
|
||||
'emby': 'Emby',
|
||||
'subsonic': 'Subsonic',
|
||||
'opensubsonic': 'OpenSubsonic',
|
||||
'gmusic': 'Google Music',
|
||||
'bandcamp': 'Bandcamp',
|
||||
'youtube': 'YouTube',
|
||||
'itunes': 'iTunes/Apple',
|
||||
'spotifyfree': 'Spotify Free',
|
||||
'tunein': 'TuneIn',
|
||||
'radio_net': 'Radio.net',
|
||||
'dirble': 'Dirble',
|
||||
'freq': 'FREQ',
|
||||
'radionomy': 'Radionomy',
|
||||
};
|
||||
return names[domain] ?? domain.split('_').map((s) => s[0].toUpperCase() + s.substring(1)).join(' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
|
||||
/// Service for managing player queue operations.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate queue logic including:
|
||||
/// - Queue fetching from API with player ID translation
|
||||
/// - Queue caching to database for instant display
|
||||
/// - Queue restoration from cache on app resume
|
||||
/// - Track validation for queue items
|
||||
class QueueManagerService {
|
||||
final MusicAssistantAPI? _api;
|
||||
final DatabaseService _database;
|
||||
final DebugLogger _logger;
|
||||
final List<Player> Function() getAvailablePlayers;
|
||||
final Map<String, String> Function() getCastToSendspinMap;
|
||||
final Future<void> Function(String playerId, List<Track> tracks, {int startIndex}) playTracks;
|
||||
|
||||
QueueManagerService({
|
||||
required MusicAssistantAPI? api,
|
||||
required DatabaseService database,
|
||||
required DebugLogger logger,
|
||||
required this.getAvailablePlayers,
|
||||
required this.getCastToSendspinMap,
|
||||
required this.playTracks,
|
||||
}) : _api = api,
|
||||
_database = database,
|
||||
_logger = logger;
|
||||
|
||||
/// Fetch queue for a player from the API.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Group child players (fetches leader's queue)
|
||||
/// - Cast-to-Sendspin ID translation
|
||||
/// - Automatic caching to database
|
||||
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||
final effectivePlayerId = _getEffectivePlayerId(playerId);
|
||||
|
||||
final queue = await _api?.getQueue(effectivePlayerId);
|
||||
|
||||
// Persist queue to database for instant display on app resume
|
||||
if (queue != null) {
|
||||
persistQueueToDatabase(playerId, queue);
|
||||
}
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
/// Get cached queue for instant display (before API refresh).
|
||||
Future<PlayerQueue?> getCachedQueue(String playerId) async {
|
||||
try {
|
||||
if (!_database.isInitialized) return null;
|
||||
|
||||
final cachedItems = await _database.getCachedQueue(playerId);
|
||||
if (cachedItems.isEmpty) return null;
|
||||
|
||||
final items = <QueueItem>[];
|
||||
for (final cached in cachedItems) {
|
||||
try {
|
||||
final itemData = jsonDecode(cached.itemJson) as Map<String, dynamic>;
|
||||
items.add(QueueItem.fromJson(itemData));
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error parsing cached queue item: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty) return null;
|
||||
|
||||
return PlayerQueue(
|
||||
playerId: playerId,
|
||||
items: items,
|
||||
currentIndex: 0, // Will be updated from fresh data
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading cached queue: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist queue to database for app restart persistence.
|
||||
void persistQueueToDatabase(String playerId, PlayerQueue queue) {
|
||||
() async {
|
||||
try {
|
||||
if (!_database.isInitialized) return;
|
||||
|
||||
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
|
||||
await _database.saveQueue(playerId, itemJsonList);
|
||||
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error persisting queue to database: $e');
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
/// Attempt to restore the queue from cached data and start playback.
|
||||
Future<bool> restoreQueueFromCache(String playerId) async {
|
||||
try {
|
||||
final cachedQueue = await getCachedQueue(playerId);
|
||||
if (cachedQueue == null || cachedQueue.items.isEmpty) {
|
||||
_logger.log('⚠️ No cached queue to restore');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract and validate tracks from queue items
|
||||
final tracks = _validateQueueTracks(cachedQueue.items);
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
_logger.log('⚠️ Cached queue has no valid tracks');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('🔄 Restoring ${tracks.length} tracks from cached queue');
|
||||
|
||||
// Re-queue all tracks and start playback
|
||||
await playTracks(playerId, tracks, startIndex: 0);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error restoring queue from cache: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and filter tracks from queue items.
|
||||
///
|
||||
/// Returns only tracks that can be played (have available provider mappings
|
||||
/// or valid provider/itemId combination).
|
||||
List<Track> _validateQueueTracks(List<QueueItem> queueItems) {
|
||||
return queueItems
|
||||
.map((item) => item.track)
|
||||
.where((track) {
|
||||
// Check for providerMappings with at least one available entry
|
||||
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
||||
return track.providerMappings!.any((m) => m.available);
|
||||
}
|
||||
// Fallback: provider + itemId can be used to construct URI
|
||||
return track.provider.isNotEmpty && track.itemId.isNotEmpty;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get the effective player ID for queue operations.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Group child players (returns leader's ID)
|
||||
/// - Cast-to-Sendspin ID translation
|
||||
String _getEffectivePlayerId(String playerId) {
|
||||
String effectivePlayerId = playerId;
|
||||
final availablePlayers = getAvailablePlayers();
|
||||
final castToSendspinMap = getCastToSendspinMap();
|
||||
|
||||
final player = availablePlayers.firstWhere(
|
||||
(p) => p.playerId == playerId,
|
||||
orElse: () => Player(
|
||||
playerId: playerId,
|
||||
name: '',
|
||||
available: false,
|
||||
powered: false,
|
||||
state: 'idle',
|
||||
),
|
||||
);
|
||||
|
||||
// If this player is a group child, fetch the leader's queue instead
|
||||
// This ensures grouped players show the same queue as their leader
|
||||
if (player.isGroupChild && player.syncedTo != null) {
|
||||
_logger.log('🔗 Player $playerId is grouped, fetching leader queue: ${player.syncedTo}');
|
||||
effectivePlayerId = player.syncedTo!;
|
||||
}
|
||||
|
||||
// Translate Sendspin ID to Cast UUID for queue fetch
|
||||
// MA stores queues under the Cast UUID, not the Sendspin ID
|
||||
effectivePlayerId = _translateSendspinToCastId(effectivePlayerId, availablePlayers, castToSendspinMap);
|
||||
|
||||
return effectivePlayerId;
|
||||
}
|
||||
|
||||
/// Translate Sendspin ID to Cast UUID for queue operations.
|
||||
///
|
||||
/// MA stores queues under the Cast UUID, not the Sendspin ID.
|
||||
/// Format: cast-7df484e3 -> need Cast UUID starting with 7df484e3
|
||||
String _translateSendspinToCastId(
|
||||
String playerId,
|
||||
List<Player> availablePlayers,
|
||||
Map<String, String> castToSendspinMap,
|
||||
) {
|
||||
if (!playerId.startsWith('cast-') || playerId.length < 13) {
|
||||
return playerId;
|
||||
}
|
||||
|
||||
final prefix = playerId.substring(5); // Remove "cast-"
|
||||
|
||||
// Reverse lookup in the map
|
||||
for (final entry in castToSendspinMap.entries) {
|
||||
if (entry.value == playerId) {
|
||||
_logger.log('🔗 Translated Sendspin ID $playerId to Cast UUID ${entry.key} for queue fetch');
|
||||
return entry.key;
|
||||
}
|
||||
}
|
||||
|
||||
// If not in map, try to find in available players
|
||||
for (final p in availablePlayers) {
|
||||
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
|
||||
_logger.log('🔗 Found Cast UUID ${p.playerId} for Sendspin ID $playerId via player lookup');
|
||||
return p.playerId;
|
||||
}
|
||||
}
|
||||
|
||||
return playerId;
|
||||
}
|
||||
|
||||
/// Clear cached queue for a player.
|
||||
Future<void> clearCachedQueue(String playerId) async {
|
||||
await _database.clearCachedQueue(playerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import '../models/media_item.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
|
||||
/// Service for handling search operations with caching.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate search logic.
|
||||
class SearchService {
|
||||
final MusicAssistantAPI? _api;
|
||||
final CacheService _cacheService;
|
||||
final DebugLogger _logger;
|
||||
|
||||
SearchService({
|
||||
required MusicAssistantAPI? api,
|
||||
required CacheService cacheService,
|
||||
required DebugLogger logger,
|
||||
}) : _api = api,
|
||||
_cacheService = cacheService,
|
||||
_logger = logger;
|
||||
|
||||
/// Search with caching support.
|
||||
///
|
||||
/// Returns cached results if available and not expired.
|
||||
/// Otherwise fetches from API and caches the results.
|
||||
Future<Map<String, List<MediaItem>>> searchWithCache(
|
||||
String query, {
|
||||
bool forceRefresh = false,
|
||||
bool libraryOnly = false,
|
||||
}) async {
|
||||
final baseKey = query.toLowerCase().trim();
|
||||
if (baseKey.isEmpty) {
|
||||
return _emptySearchResults();
|
||||
}
|
||||
|
||||
// Include libraryOnly in cache key to separate results
|
||||
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
|
||||
|
||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
|
||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? _emptySearchResults();
|
||||
}
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Searching for "$query" (libraryOnly: $libraryOnly)...');
|
||||
final results = await _api!.search(query, libraryOnly: libraryOnly);
|
||||
|
||||
final cachedResults = <String, List<MediaItem>>{
|
||||
'artists': results['artists'] ?? [],
|
||||
'albums': results['albums'] ?? [],
|
||||
'tracks': results['tracks'] ?? [],
|
||||
'playlists': results['playlists'] ?? [],
|
||||
'audiobooks': results['audiobooks'] ?? [],
|
||||
};
|
||||
|
||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||
_logger.log('✅ Cached search results for "$query"');
|
||||
return cachedResults;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Search failed: $e');
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? _emptySearchResults();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all search and detail caches.
|
||||
void clearAllDetailCaches() {
|
||||
_cacheService.clearAllDetailCaches();
|
||||
}
|
||||
|
||||
/// Check if search cache is valid for a query.
|
||||
bool isSearchCacheValid(String query, {bool forceRefresh = false}) {
|
||||
final baseKey = query.toLowerCase().trim();
|
||||
return _cacheService.isSearchCacheValid(baseKey, forceRefresh: forceRefresh);
|
||||
}
|
||||
|
||||
/// Get cached search results for a query.
|
||||
Map<String, List<MediaItem>>? getCachedSearchResults(String query, {bool libraryOnly = false}) {
|
||||
final baseKey = query.toLowerCase().trim();
|
||||
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
|
||||
return _cacheService.getCachedSearchResults(cacheKey);
|
||||
}
|
||||
|
||||
Map<String, List<MediaItem>> _emptySearchResults() {
|
||||
return {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
'playlists': [],
|
||||
'audiobooks': [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -657,7 +657,10 @@ class SendspinService {
|
||||
void _updateState(SendspinConnectionState newState) {
|
||||
if (_state != newState) {
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
// Don't try to emit if disposed or controller is closed
|
||||
if (!_isDisposed && !_stateController.isClosed) {
|
||||
_stateController.add(newState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,12 +90,11 @@ 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';
|
||||
static const String _keyShowDiscoveryFolders = 'show_discovery_folders';
|
||||
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
|
||||
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
|
||||
static const String _keyShowDiscoverSeries = 'show_discover_series';
|
||||
@@ -108,20 +107,11 @@ class SettingsService {
|
||||
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
|
||||
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
|
||||
|
||||
// Default row order
|
||||
// Default row order (only these 3 rows enabled by default on first launch)
|
||||
static const List<String> defaultHomeRowOrder = [
|
||||
'recent-albums',
|
||||
'discover-artists',
|
||||
'discover-albums',
|
||||
'continue-listening',
|
||||
'discover-audiobooks',
|
||||
'discover-series',
|
||||
'favorite-albums',
|
||||
'favorite-artists',
|
||||
'favorite-tracks',
|
||||
'favorite-playlists',
|
||||
'favorite-radio-stations',
|
||||
'favorite-podcasts',
|
||||
];
|
||||
|
||||
// View Mode Settings
|
||||
@@ -152,6 +142,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';
|
||||
@@ -178,6 +169,9 @@ class SettingsService {
|
||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||
|
||||
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
|
||||
static const String _keyDiscoveryRowPreferences = 'discovery_row_preferences';
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
@@ -354,18 +348,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) {
|
||||
@@ -400,6 +386,10 @@ class SettingsService {
|
||||
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
|
||||
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
|
||||
|
||||
// Home Screen Discovery Folders (default off - provider mixes like Tidal)
|
||||
static Future<bool> getShowDiscoveryFolders() => _getBool(_keyShowDiscoveryFolders, defaultValue: false);
|
||||
static Future<void> setShowDiscoveryFolders(bool show) => _setBool(_keyShowDiscoveryFolders, show);
|
||||
|
||||
// Home Screen Audiobook Rows (default off - optional)
|
||||
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
|
||||
static Future<void> setShowContinueListeningAudiobooks(bool show) => _setBool(_keyShowContinueListeningAudiobooks, show);
|
||||
@@ -692,6 +682,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();
|
||||
@@ -937,7 +938,7 @@ class SettingsService {
|
||||
/// for fine-grained control (10x more precise)
|
||||
static Future<bool> getVolumePrecisionMode() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
|
||||
return prefs.getBool(_keyVolumePrecisionMode) ?? false;
|
||||
}
|
||||
|
||||
/// Set whether volume precision mode is enabled
|
||||
@@ -974,4 +975,46 @@ class SettingsService {
|
||||
cache[podcastId] = url;
|
||||
await setPodcastCoverCache(cache);
|
||||
}
|
||||
|
||||
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
|
||||
|
||||
/// Get all discovery row preferences as a map (itemId -> enabled)
|
||||
/// Defaults to all enabled (true) if not set
|
||||
static Future<Map<String, bool>> getDiscoveryRowPreferences() async {
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyDiscoveryRowPreferences);
|
||||
if (json == null) return {}; // Empty map = all enabled by default
|
||||
try {
|
||||
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||
return decoded.map((key, value) => MapEntry(key, value as bool));
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Set all discovery row preferences at once
|
||||
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async {
|
||||
final sharedPrefs = await _getPrefs();
|
||||
await sharedPrefs.setString(_keyDiscoveryRowPreferences, jsonEncode(prefs));
|
||||
}
|
||||
|
||||
/// Get whether a specific discovery row is enabled
|
||||
/// Returns false if not explicitly set (default off)
|
||||
static Future<bool> getDiscoveryRowPreference(String itemId) async {
|
||||
final allPrefs = await getDiscoveryRowPreferences();
|
||||
return allPrefs[itemId] ?? false; // Default to disabled
|
||||
}
|
||||
|
||||
/// Set whether a specific discovery row is enabled
|
||||
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async {
|
||||
final allPrefs = await getDiscoveryRowPreferences();
|
||||
allPrefs[itemId] = enabled;
|
||||
await setDiscoveryRowPreferences(allPrefs);
|
||||
}
|
||||
|
||||
/// Clear all discovery row preferences (reverts to all enabled)
|
||||
static Future<void> clearDiscoveryRowPreferences() async {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_keyDiscoveryRowPreferences);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||