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
|
||||
@@ -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,7 +58,7 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
applicationId "com.collotsspot.ensemble"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@ buildscript {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.7.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.9-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -22,7 +22,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.7.0" apply false
|
||||
id "com.android.application" version "8.9.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -1655,53 +1715,58 @@
|
||||
"description": "Hint for selecting player to add to queue"
|
||||
},
|
||||
|
||||
"localConnection": "Server URL",
|
||||
"@localConnection": {
|
||||
"description": "Direct connection mode toggle label"
|
||||
"aboutAndSupport": "About & Support",
|
||||
"@aboutAndSupport": {
|
||||
"description": "About and support section title"
|
||||
},
|
||||
|
||||
"remoteConnection": "Remote Access ID",
|
||||
"@remoteConnection": {
|
||||
"description": "HA Remote Access connection mode toggle label"
|
||||
"supportDevelopment": "Support Development",
|
||||
"@supportDevelopment": {
|
||||
"description": "Support development section"
|
||||
},
|
||||
|
||||
"remoteAccessId": "Remote Access ID",
|
||||
"@remoteAccessId": {
|
||||
"description": "Remote access ID field label"
|
||||
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.",
|
||||
"@ensembleIsUnofficial": {
|
||||
"description": "Description about Ensemble being unofficial"
|
||||
},
|
||||
|
||||
"remoteAccessIdHint": "Find this in Music Assistant Settings → Remote Access",
|
||||
"@remoteAccessIdHint": {
|
||||
"description": "Hint for remote access ID field"
|
||||
"sponsorOnGitHub": "Sponsor on GitHub",
|
||||
"@sponsorOnGitHub": {
|
||||
"description": "GitHub Sponsors button label"
|
||||
},
|
||||
|
||||
"remoteAccessNote": "For Home Assistant add-on users only",
|
||||
"@remoteAccessNote": {
|
||||
"description": "Note about remote access availability"
|
||||
"buyMeAKoFi": "Buy me a Ko-fi",
|
||||
"@buyMeAKoFi": {
|
||||
"description": "Ko-fi donation button label"
|
||||
},
|
||||
|
||||
"pleaseEnterRemoteId": "Please enter your Remote Access ID",
|
||||
"@pleaseEnterRemoteId": {
|
||||
"description": "Validation message for remote access ID"
|
||||
"aboutEnsemble": "About Ensemble",
|
||||
"@aboutEnsemble": {
|
||||
"description": "About Ensemble section"
|
||||
},
|
||||
|
||||
"remoteConnectionFailed": "Remote connection failed. Please check your Remote Access ID and credentials.",
|
||||
"@remoteConnectionFailed": {
|
||||
"description": "Error message when remote connection fails"
|
||||
"version": "Version",
|
||||
"@version": {
|
||||
"description": "Version label"
|
||||
},
|
||||
|
||||
"scanQrCode": "Scan QR Code",
|
||||
"@scanQrCode": {
|
||||
"description": "Title for QR code scanner screen"
|
||||
"visitGitHubRepo": "Visit GitHub Repository",
|
||||
"@visitGitHubRepo": {
|
||||
"description": "Link to GitHub repo"
|
||||
},
|
||||
|
||||
"pointCameraAtQr": "Point your camera at the Remote Access QR code in Music Assistant settings",
|
||||
"@pointCameraAtQr": {
|
||||
"description": "Instructions for scanning QR code"
|
||||
"reportABug": "Report a Bug",
|
||||
"@reportABug": {
|
||||
"description": "Link to report issues"
|
||||
},
|
||||
|
||||
"cameraPermissionDenied": "Camera permission is required to scan QR codes",
|
||||
"@cameraPermissionDenied": {
|
||||
"description": "Error message when camera permission is denied"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -164,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
|
||||
@@ -359,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:
|
||||
@@ -761,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:
|
||||
@@ -1229,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:
|
||||
@@ -1337,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:
|
||||
@@ -1364,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.
|
||||
@@ -1673,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:
|
||||
@@ -1835,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:
|
||||
@@ -2123,65 +2219,71 @@ abstract class S {
|
||||
/// **'Select player to add to queue'**
|
||||
String get selectPlayerToAddToQueue;
|
||||
|
||||
/// Direct connection mode toggle label
|
||||
/// About and support section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Server URL'**
|
||||
String get localConnection;
|
||||
/// **'About & Support'**
|
||||
String get aboutAndSupport;
|
||||
|
||||
/// HA Remote Access connection mode toggle label
|
||||
/// Support development section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remote Access ID'**
|
||||
String get remoteConnection;
|
||||
/// **'Support Development'**
|
||||
String get supportDevelopment;
|
||||
|
||||
/// Remote access ID field label
|
||||
/// Description about Ensemble being unofficial
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remote Access ID'**
|
||||
String get remoteAccessId;
|
||||
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'**
|
||||
String get ensembleIsUnofficial;
|
||||
|
||||
/// Hint for remote access ID field
|
||||
/// GitHub Sponsors button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Find this in Music Assistant Settings → Remote Access'**
|
||||
String get remoteAccessIdHint;
|
||||
/// **'Sponsor on GitHub'**
|
||||
String get sponsorOnGitHub;
|
||||
|
||||
/// Note about remote access availability
|
||||
/// Ko-fi donation button label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'For Home Assistant add-on users only'**
|
||||
String get remoteAccessNote;
|
||||
/// **'Buy me a Ko-fi'**
|
||||
String get buyMeAKoFi;
|
||||
|
||||
/// Validation message for remote access ID
|
||||
/// About Ensemble section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please enter your Remote Access ID'**
|
||||
String get pleaseEnterRemoteId;
|
||||
/// **'About Ensemble'**
|
||||
String get aboutEnsemble;
|
||||
|
||||
/// Error message when remote connection fails
|
||||
/// Version label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remote connection failed. Please check your Remote Access ID and credentials.'**
|
||||
String get remoteConnectionFailed;
|
||||
/// **'Version'**
|
||||
String get version;
|
||||
|
||||
/// Title for QR code scanner screen
|
||||
/// Link to GitHub repo
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan QR Code'**
|
||||
String get scanQrCode;
|
||||
/// **'Visit GitHub Repository'**
|
||||
String get visitGitHubRepo;
|
||||
|
||||
/// Instructions for scanning QR code
|
||||
/// Link to report issues
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Point your camera at the Remote Access QR code in Music Assistant settings'**
|
||||
String get pointCameraAtQr;
|
||||
/// **'Report a Bug'**
|
||||
String get reportABug;
|
||||
|
||||
/// Error message when camera permission is denied
|
||||
/// Empty state when no home screen rows are enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Camera permission is required to scan QR codes'**
|
||||
String get cameraPermissionDenied;
|
||||
/// **'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> {
|
||||
|
||||
@@ -151,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';
|
||||
|
||||
@@ -373,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';
|
||||
|
||||
@@ -646,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';
|
||||
|
||||
@@ -705,6 +739,9 @@ class SDe extends S {
|
||||
String get chooseHomeScreenRows =>
|
||||
'Wähle welche Zeilen auf dem Startbildschirm angezeigt werden';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Zu Favoriten hinzugefügt';
|
||||
|
||||
@@ -718,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';
|
||||
@@ -887,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...';
|
||||
@@ -984,6 +1027,13 @@ class SDe extends S {
|
||||
String get showHintsDescription =>
|
||||
'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
|
||||
|
||||
@override
|
||||
String get volumePrecisionMode => 'Volume Precision Mode';
|
||||
|
||||
@override
|
||||
String get volumePrecisionModeDescription =>
|
||||
'Hold still while adjusting volume for fine control';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@@ -1157,36 +1207,37 @@ class SDe extends S {
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get localConnection => 'Server URL';
|
||||
String get aboutAndSupport => 'Über & Unterstützung';
|
||||
|
||||
@override
|
||||
String get remoteConnection => 'Remote Access ID';
|
||||
String get supportDevelopment => 'Entwicklung unterstützen';
|
||||
|
||||
@override
|
||||
String get remoteAccessId => 'Remote Access ID';
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
|
||||
|
||||
@override
|
||||
String get remoteAccessIdHint =>
|
||||
'Find this in Music Assistant Settings → Remote Access';
|
||||
String get sponsorOnGitHub => 'Auf GitHub sponsoren';
|
||||
|
||||
@override
|
||||
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||
String get buyMeAKoFi => 'Kauf mir einen Ko-fi';
|
||||
|
||||
@override
|
||||
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||
String get aboutEnsemble => 'Über Ensemble';
|
||||
|
||||
@override
|
||||
String get remoteConnectionFailed =>
|
||||
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get scanQrCode => 'Scan QR Code';
|
||||
String get visitGitHubRepo => 'GitHub-Repository besuchen';
|
||||
|
||||
@override
|
||||
String get pointCameraAtQr =>
|
||||
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||
String get reportABug => 'Einen Fehler melden';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied =>
|
||||
'Camera permission is required to scan QR codes';
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -39,8 +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';
|
||||
@@ -149,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';
|
||||
|
||||
@@ -369,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';
|
||||
|
||||
@@ -641,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';
|
||||
|
||||
@@ -698,6 +731,9 @@ class SEn extends S {
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choose which rows to display on the home screen';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Added to favorites';
|
||||
|
||||
@@ -711,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';
|
||||
@@ -879,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...';
|
||||
@@ -976,6 +1018,13 @@ class SEn extends S {
|
||||
String get showHintsDescription =>
|
||||
'Display helpful tips for discovering features';
|
||||
|
||||
@override
|
||||
String get volumePrecisionMode => 'Volume Precision Mode';
|
||||
|
||||
@override
|
||||
String get volumePrecisionModeDescription =>
|
||||
'Hold still while adjusting volume for fine control';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@@ -1149,36 +1198,37 @@ class SEn extends S {
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get localConnection => 'Server URL';
|
||||
String get aboutAndSupport => 'About & Support';
|
||||
|
||||
@override
|
||||
String get remoteConnection => 'Remote Access ID';
|
||||
String get supportDevelopment => 'Support Development';
|
||||
|
||||
@override
|
||||
String get remoteAccessId => 'Remote Access ID';
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
|
||||
|
||||
@override
|
||||
String get remoteAccessIdHint =>
|
||||
'Find this in Music Assistant Settings → Remote Access';
|
||||
String get sponsorOnGitHub => 'Sponsor on GitHub';
|
||||
|
||||
@override
|
||||
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||
String get buyMeAKoFi => 'Buy me a Ko-fi';
|
||||
|
||||
@override
|
||||
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||
String get aboutEnsemble => 'About Ensemble';
|
||||
|
||||
@override
|
||||
String get remoteConnectionFailed =>
|
||||
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get scanQrCode => 'Scan QR Code';
|
||||
String get visitGitHubRepo => 'Visit GitHub Repository';
|
||||
|
||||
@override
|
||||
String get pointCameraAtQr =>
|
||||
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||
String get reportABug => 'Report a Bug';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied =>
|
||||
'Camera permission is required to scan QR codes';
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -152,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';
|
||||
|
||||
@@ -376,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';
|
||||
|
||||
@@ -650,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';
|
||||
|
||||
@@ -712,6 +746,9 @@ class SEs extends S {
|
||||
String get chooseHomeScreenRows =>
|
||||
'Elige qué filas mostrar en la pantalla de inicio';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Añadido a favoritos';
|
||||
|
||||
@@ -725,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';
|
||||
@@ -895,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...';
|
||||
@@ -992,6 +1035,13 @@ class SEs extends S {
|
||||
String get showHintsDescription =>
|
||||
'Mostrar consejos útiles para descubrir funciones';
|
||||
|
||||
@override
|
||||
String get volumePrecisionMode => 'Volume Precision Mode';
|
||||
|
||||
@override
|
||||
String get volumePrecisionModeDescription =>
|
||||
'Hold still while adjusting volume for fine control';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@@ -1165,36 +1215,37 @@ class SEs extends S {
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get localConnection => 'Server URL';
|
||||
String get aboutAndSupport => 'Acerca de y Soporte';
|
||||
|
||||
@override
|
||||
String get remoteConnection => 'Remote Access ID';
|
||||
String get supportDevelopment => 'Apoyar el Desarrollo';
|
||||
|
||||
@override
|
||||
String get remoteAccessId => 'Remote Access ID';
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
|
||||
|
||||
@override
|
||||
String get remoteAccessIdHint =>
|
||||
'Find this in Music Assistant Settings → Remote Access';
|
||||
String get sponsorOnGitHub => 'Patrocina en GitHub';
|
||||
|
||||
@override
|
||||
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||
String get buyMeAKoFi => 'Cómprame un Ko-fi';
|
||||
|
||||
@override
|
||||
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||
String get aboutEnsemble => 'Acerca de Ensemble';
|
||||
|
||||
@override
|
||||
String get remoteConnectionFailed =>
|
||||
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||
String get version => 'Versión';
|
||||
|
||||
@override
|
||||
String get scanQrCode => 'Scan QR Code';
|
||||
String get visitGitHubRepo => 'Visitar Repositorio GitHub';
|
||||
|
||||
@override
|
||||
String get pointCameraAtQr =>
|
||||
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||
String get reportABug => 'Reportar un Error';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied =>
|
||||
'Camera permission is required to scan QR codes';
|
||||
String get noRowsEnabled => 'No rows enabled';
|
||||
|
||||
@override
|
||||
String get pullToRefreshHint =>
|
||||
'Pull down to refresh or enable rows in settings';
|
||||
}
|
||||
|
||||
@@ -153,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';
|
||||
|
||||
@@ -377,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';
|
||||
|
||||
@@ -651,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';
|
||||
|
||||
@@ -716,6 +750,9 @@ class SFr extends S {
|
||||
String get chooseHomeScreenRows =>
|
||||
'Choisir les rangees a afficher sur l\'ecran d\'accueil';
|
||||
|
||||
@override
|
||||
String get favorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get addedToFavorites => 'Ajoute aux favoris';
|
||||
|
||||
@@ -902,9 +939,15 @@ class SFr extends S {
|
||||
String get addAlbumToQueueOn =>
|
||||
'Ajouter l\'album a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String get addToQueue => 'Add to queue';
|
||||
|
||||
@override
|
||||
String get addToQueueOn => 'Ajouter a la file d\'attente sur...';
|
||||
|
||||
@override
|
||||
String get startRadio => 'Start radio';
|
||||
|
||||
@override
|
||||
String startRadioOn(String name) {
|
||||
return 'Demarrer la radio $name sur...';
|
||||
@@ -1000,6 +1043,13 @@ class SFr extends S {
|
||||
String get showHintsDescription =>
|
||||
'Afficher des conseils utiles pour découvrir les fonctionnalites';
|
||||
|
||||
@override
|
||||
String get volumePrecisionMode => 'Volume Precision Mode';
|
||||
|
||||
@override
|
||||
String get volumePrecisionModeDescription =>
|
||||
'Hold still while adjusting volume for fine control';
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@@ -1174,36 +1224,37 @@ class SFr extends S {
|
||||
String get selectPlayerToAddToQueue => 'Select player to add to queue';
|
||||
|
||||
@override
|
||||
String get localConnection => 'Server URL';
|
||||
String get aboutAndSupport => 'À propos et Support';
|
||||
|
||||
@override
|
||||
String get remoteConnection => 'Remote Access ID';
|
||||
String get supportDevelopment => 'Soutenir le Développement';
|
||||
|
||||
@override
|
||||
String get remoteAccessId => 'Remote Access ID';
|
||||
String get ensembleIsUnofficial =>
|
||||
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
|
||||
|
||||
@override
|
||||
String get remoteAccessIdHint =>
|
||||
'Find this in Music Assistant Settings → Remote Access';
|
||||
String get sponsorOnGitHub => 'Sponsoriser sur GitHub';
|
||||
|
||||
@override
|
||||
String get remoteAccessNote => 'For Home Assistant add-on users only';
|
||||
String get buyMeAKoFi => 'Offrez-moi un Ko-fi';
|
||||
|
||||
@override
|
||||
String get pleaseEnterRemoteId => 'Please enter your Remote Access ID';
|
||||
String get aboutEnsemble => 'À propos d\'Ensemble';
|
||||
|
||||
@override
|
||||
String get remoteConnectionFailed =>
|
||||
'Remote connection failed. Please check your Remote Access ID and credentials.';
|
||||
String get version => 'Version';
|
||||
|
||||
@override
|
||||
String get scanQrCode => 'Scan QR Code';
|
||||
String get visitGitHubRepo => 'Visiter le Dépôt GitHub';
|
||||
|
||||
@override
|
||||
String get pointCameraAtQr =>
|
||||
'Point your camera at the Remote Access QR code in Music Assistant settings';
|
||||
String get reportABug => 'Signaler un Bug';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied =>
|
||||
'Camera permission is required to scan QR codes';
|
||||
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,28 +450,13 @@ 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
|
||||
// Also include hidden WebView for remote WebRTC bridge
|
||||
final musicProvider = context.read<MusicAssistantProvider>();
|
||||
return Stack(
|
||||
children: [
|
||||
GlobalPlayerOverlay(child: child ?? const SizedBox.shrink()),
|
||||
// WebView for WebRTC - must be VISIBLE to avoid Android throttling
|
||||
// A tiny 4x4 pixel dot at bottom-right, nearly invisible but keeps WebView active
|
||||
Positioned(
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
child: Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.1), // Nearly invisible dot
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: musicProvider.remoteBridgeWebView,
|
||||
),
|
||||
// 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -474,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
|
||||
|
||||
@@ -31,20 +31,14 @@ class ProviderMapping {
|
||||
});
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
// Parse in_library field - MA uses 1/0 or true/false
|
||||
// Default to true if not present (items from library_items API are in library)
|
||||
final inLibraryValue = json['in_library'];
|
||||
final inLibrary = inLibraryValue == null
|
||||
? true // Default: items fetched from library are in library
|
||||
: inLibraryValue == true || inLibraryValue == 1;
|
||||
|
||||
return ProviderMapping(
|
||||
itemId: json['item_id'] as String? ?? '',
|
||||
providerDomain: json['provider_domain'] as String? ?? '',
|
||||
providerInstance: json['provider_instance'] as String? ?? '',
|
||||
available: json['available'] as bool? ?? true,
|
||||
audioFormat: json['audio_format'] as Map<String, dynamic>?,
|
||||
inLibrary: inLibrary,
|
||||
// Parse in_library field - MA uses 1/0 or true/false
|
||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,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 {
|
||||
@@ -171,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();
|
||||
}
|
||||
@@ -292,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();
|
||||
@@ -338,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 {
|
||||
@@ -534,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(
|
||||
|
||||
@@ -41,12 +41,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
|
||||
|
||||
void _copyLogs() {
|
||||
// Copy only the filtered entries that are currently displayed
|
||||
final filteredLogs = _filteredEntries.map((e) => e.formatted).join('\n');
|
||||
Clipboard.setData(ClipboardData(text: filteredLogs));
|
||||
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${S.of(context)!.logsCopied} (${_filteredEntries.length} entries)'),
|
||||
content: Text(S.of(context)!.logsCopied),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -63,8 +61,6 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
report,
|
||||
subject: S.of(context)!.ensembleBugReport,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.error('Share failed', error: e);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isGeneratingReport = false);
|
||||
@@ -368,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;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/recommendation_folder.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -17,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';
|
||||
@@ -48,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;
|
||||
@@ -57,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() {
|
||||
@@ -67,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);
|
||||
@@ -97,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
|
||||
@@ -157,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;
|
||||
@@ -172,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 {
|
||||
@@ -227,21 +338,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
titleSpacing: 0,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
// Copy logs button for debugging
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report_outlined, size: 20),
|
||||
onPressed: () {
|
||||
final logs = DebugLogger().entries.map((e) => e.formatted).join('\n');
|
||||
Clipboard.setData(ClipboardData(text: logs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${DebugLogger().entries.length} log entries copied'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Copy logs',
|
||||
),
|
||||
// Sync indicator - shows when library is syncing in background
|
||||
ListenableBuilder(
|
||||
listenable: SyncService.instance,
|
||||
@@ -342,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;
|
||||
@@ -364,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
|
||||
@@ -402,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -415,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) {
|
||||
@@ -426,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// A screen for scanning QR codes to get the Remote Access ID.
|
||||
class QrScannerScreen extends StatefulWidget {
|
||||
const QrScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QrScannerScreen> createState() => _QrScannerScreenState();
|
||||
}
|
||||
|
||||
class _QrScannerScreenState extends State<QrScannerScreen> {
|
||||
late final MobileScannerController _controller;
|
||||
bool _hasScanned = false;
|
||||
String _statusMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.normal,
|
||||
facing: CameraFacing.back,
|
||||
formats: [BarcodeFormat.qrCode], // Only scan QR codes
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_hasScanned) return;
|
||||
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
for (final barcode in barcodes) {
|
||||
final String? code = barcode.rawValue;
|
||||
if (code != null && code.isNotEmpty) {
|
||||
final remoteId = _extractRemoteId(code);
|
||||
|
||||
if (remoteId != null && remoteId.isNotEmpty) {
|
||||
_hasScanned = true;
|
||||
Navigator.of(context).pop(remoteId);
|
||||
return;
|
||||
} else {
|
||||
// Show what was scanned for debugging
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Scanned: $code'),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the remote ID from various QR code formats
|
||||
String? _extractRemoteId(String code) {
|
||||
// Format 1: Raw remote ID (alphanumeric)
|
||||
if (RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(code) && code.length >= 8) {
|
||||
return code;
|
||||
}
|
||||
|
||||
// Format 2: ma://remote/<id>
|
||||
if (code.startsWith('ma://remote/')) {
|
||||
return code.replaceFirst('ma://remote/', '');
|
||||
}
|
||||
|
||||
// Format 3: URL from app.music-assistant.io
|
||||
// Examples:
|
||||
// - https://app.music-assistant.io/?cloudInstanceId=<id>
|
||||
// - https://app.music-assistant.io/#/home?cloudInstanceId=<id>
|
||||
if (code.contains('music-assistant.io')) {
|
||||
final uri = Uri.tryParse(code);
|
||||
if (uri != null) {
|
||||
// Check query parameters in main URL
|
||||
final queryParams = ['cloudInstanceId', 'cloud_instance_id', 'remoteid', 'remote_id', 'id'];
|
||||
for (final param in queryParams) {
|
||||
final value = uri.queryParameters[param];
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check fragment (hash) for query params: #/home?cloudInstanceId=xxx
|
||||
final fragment = uri.fragment;
|
||||
if (fragment.contains('?')) {
|
||||
final fragmentQuery = fragment.split('?').last;
|
||||
final fragmentParams = Uri.splitQueryString(fragmentQuery);
|
||||
for (final param in queryParams) {
|
||||
final value = fragmentParams[param];
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ID is in path segments
|
||||
for (final segment in uri.pathSegments) {
|
||||
if (segment.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(segment)) {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format 4: Other URL formats - try to extract from query params or path
|
||||
if (code.contains('://')) {
|
||||
final uri = Uri.tryParse(code);
|
||||
if (uri != null) {
|
||||
// Check common query param names
|
||||
for (final param in ['id', 'remoteid', 'remote_id', 'cloudInstanceId']) {
|
||||
final value = uri.queryParameters[param];
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
// Last path segment if it looks like an ID
|
||||
if (uri.pathSegments.isNotEmpty) {
|
||||
final last = uri.pathSegments.last;
|
||||
if (last.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(last)) {
|
||||
return last;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(S.of(context)!.scanQrCode),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
// Toggle torch
|
||||
IconButton(
|
||||
icon: ValueListenableBuilder(
|
||||
valueListenable: _controller,
|
||||
builder: (context, state, child) {
|
||||
return Icon(
|
||||
state.torchState == TorchState.on
|
||||
? Icons.flash_on
|
||||
: Icons.flash_off,
|
||||
);
|
||||
},
|
||||
),
|
||||
onPressed: () => _controller.toggleTorch(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Camera preview
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error, child) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
size: 64,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Camera error: ${error.errorCode.name}',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.errorDetails?.message ?? S.of(context)!.cameraPermissionDenied,
|
||||
style: TextStyle(color: colorScheme.error.withOpacity(0.7)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Scan overlay with instruction
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Scan frame
|
||||
Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Instruction text
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.pointCameraAtQr,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (_statusMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,542 +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';
|
||||
import '../../constants/network.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 and port
|
||||
/// For local IP addresses without an explicit port, defaults to MA port 8095
|
||||
String _normalizeUrl(String url) {
|
||||
var baseUrl = url.trim();
|
||||
|
||||
// Check if URL already has a port specified (before adding protocol)
|
||||
// Port can be specified as host:port or after protocol as http://host:port
|
||||
bool hasExplicitPort = _hasExplicitPort(baseUrl);
|
||||
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
// Use http for local IPs, https for domains
|
||||
if (_isLocalAddress(baseUrl)) {
|
||||
baseUrl = 'http://$baseUrl';
|
||||
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
|
||||
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
|
||||
baseUrl = 'http://$baseUrl';
|
||||
_logger.log('🔗 Detected Tailscale URL, using HTTP');
|
||||
} else {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
}
|
||||
|
||||
// For local addresses without explicit port, add default MA port
|
||||
// This handles the common case of MA as HAOS add-on where port 8095 is needed
|
||||
if (!hasExplicitPort) {
|
||||
final uri = Uri.parse(baseUrl);
|
||||
if (_isLocalAddress(uri.host)) {
|
||||
baseUrl = Uri(
|
||||
scheme: uri.scheme,
|
||||
host: uri.host,
|
||||
port: NetworkConstants.defaultWsPort,
|
||||
path: uri.path.isEmpty ? null : uri.path,
|
||||
).toString();
|
||||
_logger.log('🔗 Local address without port - defaulting to MA port ${NetworkConstants.defaultWsPort}');
|
||||
}
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/// Check if a URL string has an explicit port specified
|
||||
bool _hasExplicitPort(String url) {
|
||||
// Remove protocol if present
|
||||
var hostPart = url;
|
||||
if (hostPart.startsWith('http://')) {
|
||||
hostPart = hostPart.substring(7);
|
||||
} else if (hostPart.startsWith('https://')) {
|
||||
hostPart = hostPart.substring(8);
|
||||
}
|
||||
|
||||
// Remove path if present
|
||||
final pathIndex = hostPart.indexOf('/');
|
||||
if (pathIndex != -1) {
|
||||
hostPart = hostPart.substring(0, pathIndex);
|
||||
}
|
||||
|
||||
// Check for port (host:port pattern)
|
||||
// Need to handle IPv6 addresses like [::1]:8095
|
||||
if (hostPart.startsWith('[')) {
|
||||
// IPv6 address
|
||||
final closeBracket = hostPart.indexOf(']');
|
||||
if (closeBracket != -1 && closeBracket < hostPart.length - 1) {
|
||||
return hostPart[closeBracket + 1] == ':';
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
// IPv4 or hostname - check for colon followed by digits
|
||||
final colonIndex = hostPart.lastIndexOf(':');
|
||||
if (colonIndex != -1) {
|
||||
final portPart = hostPart.substring(colonIndex + 1);
|
||||
return int.tryParse(portPart) != null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an address is a local/private address (without protocol)
|
||||
bool _isLocalAddress(String address) {
|
||||
// Remove port if present for checking
|
||||
var host = address;
|
||||
final colonIndex = host.lastIndexOf(':');
|
||||
if (colonIndex != -1 && int.tryParse(host.substring(colonIndex + 1)) != null) {
|
||||
host = host.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
return host.startsWith('192.168.') ||
|
||||
host.startsWith('192.') ||
|
||||
host.startsWith('10.') ||
|
||||
host.startsWith('172.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.ts.net');
|
||||
}
|
||||
|
||||
/// Check if a hostname is a local/private IP address
|
||||
bool _isLocalIpAddress(String host) {
|
||||
return host.startsWith('192.168.') ||
|
||||
host.startsWith('10.') ||
|
||||
host.startsWith('172.16.') ||
|
||||
host.startsWith('172.17.') ||
|
||||
host.startsWith('172.18.') ||
|
||||
host.startsWith('172.19.') ||
|
||||
host.startsWith('172.2') ||
|
||||
host.startsWith('172.30.') ||
|
||||
host.startsWith('172.31.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.ts.net');
|
||||
}
|
||||
|
||||
/// Try to detect auth strategy for a given URL
|
||||
/// Returns the strategy if successful, null if connection failed
|
||||
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
|
||||
// First, check if this is a Music Assistant server with native auth
|
||||
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
|
||||
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
|
||||
if (maAuthResult != null) {
|
||||
if (maAuthResult == 'required') {
|
||||
_logger.log('✓ Detected Music Assistant native authentication');
|
||||
return _strategies.firstWhere((s) => s.name == 'music_assistant');
|
||||
} else if (maAuthResult == 'none') {
|
||||
_logger.log('✓ Music Assistant server - no auth required');
|
||||
return _strategies.firstWhere((s) => s.name == 'none');
|
||||
}
|
||||
}
|
||||
|
||||
// Try no-auth (direct connection without any auth)
|
||||
_logger.log('Testing no-auth strategy...');
|
||||
final canConnect = await _canConnectWithoutAuth(baseUrl);
|
||||
_logger.log('No-auth result: $canConnect');
|
||||
|
||||
if (canConnect) {
|
||||
_logger.log('✓ Server does not require authentication');
|
||||
return _strategies.firstWhere((s) => s.name == 'none');
|
||||
}
|
||||
|
||||
// Probe server for auth requirements (reverse proxy scenarios)
|
||||
/// 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.
|
||||
|
||||
@@ -7,9 +7,6 @@ enum ErrorType {
|
||||
network,
|
||||
playback,
|
||||
library,
|
||||
remote, // Remote connection (WebRTC) errors
|
||||
dataChannel, // WebRTC data channel errors
|
||||
signaling, // WebRTC signaling server errors
|
||||
unknown,
|
||||
}
|
||||
|
||||
@@ -110,41 +107,6 @@ class ErrorHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// WebRTC/Remote connection errors
|
||||
if (errorStr.contains('webrtc') ||
|
||||
errorStr.contains('remote peer') ||
|
||||
errorStr.contains('remote server disconnected')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.remote,
|
||||
userMessage: 'Remote connection lost. Attempting to reconnect...',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Data channel errors
|
||||
if (errorStr.contains('data channel') ||
|
||||
errorStr.contains('datachannel')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.dataChannel,
|
||||
userMessage: 'Remote data connection failed. Please try reconnecting.',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Signaling errors
|
||||
if (errorStr.contains('signaling') ||
|
||||
errorStr.contains('ice') ||
|
||||
errorStr.contains('peer connection failed')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.signaling,
|
||||
userMessage: 'Failed to establish remote connection. Please try again.',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown errors
|
||||
return ErrorInfo(
|
||||
type: ErrorType.unknown,
|
||||
|
||||
@@ -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';
|
||||
@@ -25,20 +26,6 @@ enum MAConnectionState {
|
||||
error,
|
||||
}
|
||||
|
||||
/// Wrapper for pending requests with timestamp for age-based cleanup
|
||||
class _PendingRequest {
|
||||
final Completer<Map<String, dynamic>> completer;
|
||||
final DateTime createdAt;
|
||||
|
||||
_PendingRequest({required this.completer, DateTime? createdAt})
|
||||
: createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
bool get isStale {
|
||||
const staleThreshold = Duration(minutes: 2);
|
||||
return DateTime.now().difference(createdAt) > staleThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
class MusicAssistantAPI {
|
||||
final String serverUrl;
|
||||
final AuthManager authManager;
|
||||
@@ -52,7 +39,7 @@ class MusicAssistantAPI {
|
||||
MAConnectionState _currentState = MAConnectionState.disconnected;
|
||||
MAConnectionState get currentConnectionState => _currentState;
|
||||
|
||||
final Map<String, _PendingRequest> _pendingRequests = {};
|
||||
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
|
||||
final Map<String, StreamController<Map<String, dynamic>>> _eventStreams = {};
|
||||
|
||||
Completer<void>? _connectionCompleter;
|
||||
@@ -79,12 +66,6 @@ class MusicAssistantAPI {
|
||||
final Map<String, ProviderManifest> _providerManifests = {};
|
||||
Map<String, ProviderManifest> get providerManifests => _providerManifests;
|
||||
|
||||
// Remote mode flag - when true, images go through WebRTC bridge which doesn't support HTTP
|
||||
// In this mode, we use external CDN URLs directly instead of imageproxy
|
||||
bool _isRemoteMode = false;
|
||||
bool get isRemoteMode => _isRemoteMode;
|
||||
set isRemoteMode(bool value) => _isRemoteMode = value;
|
||||
|
||||
MusicAssistantAPI(this.serverUrl, this.authManager);
|
||||
|
||||
// Guard to prevent multiple simultaneous connection attempts
|
||||
@@ -234,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'));
|
||||
@@ -305,15 +287,15 @@ class MusicAssistantAPI {
|
||||
|
||||
// Handle response to a request
|
||||
if (messageId != null && _pendingRequests.containsKey(messageId)) {
|
||||
final pending = _pendingRequests.remove(messageId);
|
||||
final completer = _pendingRequests.remove(messageId);
|
||||
|
||||
if (data.containsKey('error_code')) {
|
||||
_logger.log('Command error: ${data['error_code']} - ${data['details']}');
|
||||
pending!.completer.completeError(
|
||||
completer!.completeError(
|
||||
Exception('${data['error_code']}: ${data['details']}'),
|
||||
);
|
||||
} else {
|
||||
pending!.completer.complete(data);
|
||||
completer!.complete(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -372,9 +354,6 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale requests before checking limits
|
||||
_cleanupStaleRequests();
|
||||
|
||||
// Prevent memory leaks by limiting pending requests
|
||||
if (_pendingRequests.length >= NetworkConstants.maxPendingRequests) {
|
||||
throw Exception(
|
||||
@@ -385,7 +364,7 @@ class MusicAssistantAPI {
|
||||
|
||||
final messageId = _uuid.v4();
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
_pendingRequests[messageId] = _PendingRequest(completer: completer);
|
||||
_pendingRequests[messageId] = completer;
|
||||
|
||||
final message = {
|
||||
'message_id': messageId,
|
||||
@@ -408,27 +387,6 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up stale pending requests that have been waiting too long
|
||||
void _cleanupStaleRequests() {
|
||||
final staleIds = <String>[];
|
||||
for (final entry in _pendingRequests.entries) {
|
||||
if (entry.value.isStale && !entry.value.completer.isCompleted) {
|
||||
staleIds.add(entry.key);
|
||||
}
|
||||
}
|
||||
if (staleIds.isNotEmpty) {
|
||||
_logger.log('Cleanup: Removing ${staleIds.length} stale pending requests');
|
||||
for (final id in staleIds) {
|
||||
final pending = _pendingRequests.remove(id);
|
||||
if (pending != null && !pending.completer.isCompleted) {
|
||||
pending.completer.completeError(
|
||||
TimeoutException('Request expired after 2 minutes'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Library browsing methods
|
||||
Future<List<Artist>> getArtists({
|
||||
int? limit,
|
||||
@@ -1238,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 {
|
||||
@@ -2120,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 {
|
||||
@@ -3260,12 +3271,6 @@ class MusicAssistantAPI {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In remote mode (WebRTC bridge), HTTP requests don't work through the tunnel
|
||||
// So we need to use external CDN URLs directly when available
|
||||
if (_isRemoteMode) {
|
||||
return _getImageUrlForRemoteMode(images, size);
|
||||
}
|
||||
|
||||
// Try to find a non-remotely accessible image first (prefer local/opensubsonic)
|
||||
Map<String, dynamic>? selectedImage;
|
||||
for (var img in images) {
|
||||
@@ -3328,107 +3333,6 @@ class MusicAssistantAPI {
|
||||
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
|
||||
}
|
||||
|
||||
/// Get image URL for remote mode - uses external CDN URLs directly
|
||||
/// since HTTP requests don't work through the WebRTC bridge
|
||||
String? _getImageUrlForRemoteMode(List<dynamic> images, int size) {
|
||||
// Known external CDN domains that are publicly accessible
|
||||
const externalDomains = [
|
||||
'i.scdn.co', // Spotify
|
||||
'mosaic.scdn.co', // Spotify playlists
|
||||
'image-cdn-ak.spotifycdn.com', // Spotify
|
||||
'cdns-images.dzcdn.net', // Deezer
|
||||
'resources.tidal.com', // Tidal
|
||||
'e-cdns-images.dzcdn.net', // Deezer
|
||||
'is1-ssl.mzstatic.com', // Apple Music
|
||||
'is2-ssl.mzstatic.com', // Apple Music
|
||||
'is3-ssl.mzstatic.com', // Apple Music
|
||||
'is4-ssl.mzstatic.com', // Apple Music
|
||||
'is5-ssl.mzstatic.com', // Apple Music
|
||||
'lastfm.freetls.fastly.net', // Last.fm
|
||||
'coverartarchive.org', // MusicBrainz
|
||||
'archive.org', // Internet Archive
|
||||
'fanart.tv', // Fanart.tv
|
||||
];
|
||||
|
||||
// First pass: look for images with paths that are external URLs
|
||||
for (var img in images) {
|
||||
final imgMap = img as Map<String, dynamic>;
|
||||
final path = imgMap['path'] as String?;
|
||||
if (path == null) continue;
|
||||
|
||||
// Check if path is already an external URL
|
||||
if (path.startsWith('https://')) {
|
||||
try {
|
||||
final uri = Uri.parse(path);
|
||||
|
||||
// If URL contains imageproxy with a size parameter, replace with requested size
|
||||
if (uri.path.contains('imageproxy') && uri.queryParameters.containsKey('size')) {
|
||||
final newParams = Map<String, String>.from(uri.queryParameters);
|
||||
newParams['size'] = size.toString();
|
||||
final newUri = uri.replace(queryParameters: newParams);
|
||||
_logger.log('🖼️ Remote mode: adjusted imageproxy size to $size from ${uri.host}');
|
||||
return newUri.toString();
|
||||
}
|
||||
|
||||
// Check if it's from a known CDN
|
||||
for (final domain in externalDomains) {
|
||||
if (uri.host.contains(domain)) {
|
||||
_logger.log('🖼️ Remote mode: using external CDN URL from ${uri.host}');
|
||||
// For Spotify, we can request different sizes
|
||||
if (uri.host.contains('scdn.co') || uri.host.contains('spotifycdn.com')) {
|
||||
// Spotify image URLs can have size in path, but we'll use as-is
|
||||
return path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an Audiobookshelf URL (cover API endpoint)
|
||||
// Audiobookshelf supports ?width=X parameter for resizing
|
||||
if (uri.path.contains('/api/') && uri.path.contains('/cover')) {
|
||||
final newParams = Map<String, String>.from(uri.queryParameters);
|
||||
newParams['width'] = size.toString();
|
||||
final newUri = uri.replace(queryParameters: newParams);
|
||||
_logger.log('🖼️ Remote mode: adjusted Audiobookshelf cover size to $size from ${uri.host}');
|
||||
return newUri.toString();
|
||||
}
|
||||
|
||||
// Even if not a known CDN, if it's HTTPS it might work
|
||||
_logger.log('🖼️ Remote mode: trying external URL from ${uri.host}');
|
||||
return path;
|
||||
} catch (e) {
|
||||
// Not a valid URL, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: try any image with a provider that typically has external URLs
|
||||
for (var img in images) {
|
||||
final imgMap = img as Map<String, dynamic>;
|
||||
final provider = imgMap['provider'] as String?;
|
||||
final path = imgMap['path'] as String?;
|
||||
if (path == null) continue;
|
||||
|
||||
// Providers that typically have external URLs in their paths
|
||||
if (provider != null) {
|
||||
if (provider.contains('spotify') ||
|
||||
provider.contains('tidal') ||
|
||||
provider.contains('deezer') ||
|
||||
provider.contains('apple_music') ||
|
||||
provider.contains('musicbrainz')) {
|
||||
if (path.startsWith('https://')) {
|
||||
_logger.log('🖼️ Remote mode: using $provider image URL');
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No external URLs found - images won't load in remote mode for local files
|
||||
_logger.log('🖼️ Remote mode: no external image URL available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== iTunes Artwork Lookup ====================
|
||||
|
||||
/// Search iTunes for a podcast and return high-res artwork URL
|
||||
@@ -3828,8 +3732,8 @@ class MusicAssistantAPI {
|
||||
/// Cancel all pending requests with an error message
|
||||
void _cancelPendingRequests(String reason) {
|
||||
for (final entry in _pendingRequests.entries) {
|
||||
if (!entry.value.completer.isCompleted) {
|
||||
entry.value.completer.completeError(Exception(reason));
|
||||
if (!entry.value.isCompleted) {
|
||||
entry.value.completeError(Exception(reason));
|
||||
}
|
||||
}
|
||||
_pendingRequests.clear();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ensemble/services/remote/webview_webrtc_connection.dart';
|
||||
import 'package:ensemble/services/debug_logger.dart';
|
||||
|
||||
/// Error types for RemoteBridge
|
||||
enum RemoteBridgeErrorType {
|
||||
webrtcConnection,
|
||||
signaling,
|
||||
authentication,
|
||||
dataChannel,
|
||||
timeout,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Error information from RemoteBridge
|
||||
class RemoteBridgeError {
|
||||
final RemoteBridgeErrorType type;
|
||||
final String message;
|
||||
final DateTime timestamp;
|
||||
|
||||
RemoteBridgeError({
|
||||
required this.type,
|
||||
required this.message,
|
||||
DateTime? timestamp,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// Connection state for RemoteBridge
|
||||
enum RemoteBridgeState {
|
||||
disconnected,
|
||||
connecting,
|
||||
authenticating,
|
||||
connected,
|
||||
reconnecting,
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Connection quality metrics
|
||||
class ConnectionMetrics {
|
||||
final int requestsSent;
|
||||
final int responsesReceived;
|
||||
final int reconnectionCount;
|
||||
final Duration? lastLatency;
|
||||
final DateTime? lastActivity;
|
||||
|
||||
ConnectionMetrics({
|
||||
required this.requestsSent,
|
||||
required this.responsesReceived,
|
||||
required this.reconnectionCount,
|
||||
this.lastLatency,
|
||||
this.lastActivity,
|
||||
});
|
||||
|
||||
double get successRate {
|
||||
if (requestsSent == 0) return 1.0;
|
||||
return responsesReceived / requestsSent;
|
||||
}
|
||||
}
|
||||
|
||||
/// A WebRTC-to-WebSocket bridge that allows MusicAssistantAPI to work unchanged
|
||||
/// for remote connections. All traffic is tunneled through a local WebSocket server.
|
||||
///
|
||||
/// Architecture:
|
||||
/// ```
|
||||
/// MusicAssistantAPI → localhost:port → RemoteBridge → WebRTC → MA Server
|
||||
/// Sendspin → localhost:port/sendspin → RemoteBridge → WebRTC (sendspin channel) → MA Server
|
||||
/// ```
|
||||
class RemoteBridge {
|
||||
HttpServer? _server;
|
||||
WebViewWebRTCConnection? _webrtcConnection;
|
||||
int? _port;
|
||||
|
||||
// API client (MusicAssistantAPI)
|
||||
WebSocket? _apiClientSocket;
|
||||
StreamSubscription? _apiClientSubscription;
|
||||
StreamSubscription? _apiWebrtcSubscription;
|
||||
|
||||
// Sendspin client (local player audio)
|
||||
WebSocket? _sendspinClientSocket;
|
||||
StreamSubscription? _sendspinClientSubscription;
|
||||
StreamSubscription? _sendspinTextSubscription;
|
||||
StreamSubscription? _sendspinBinarySubscription;
|
||||
|
||||
Timer? _healthCheckTimer;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
// Connection parameters for reconnection
|
||||
String? _remoteId;
|
||||
String? _username;
|
||||
String? _password;
|
||||
bool _isReconnecting = false;
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 5;
|
||||
static const Duration _reconnectDelay = Duration(seconds: 3);
|
||||
static const Duration _rateLimitDelay = Duration(seconds: 65);
|
||||
bool _isRateLimited = false;
|
||||
|
||||
// Cached server hello to replay when WS client connects
|
||||
String? _cachedServerHello;
|
||||
|
||||
// Debug counters
|
||||
int _apiMessagesSent = 0;
|
||||
int _apiMessagesReceived = 0;
|
||||
int _sendspinMessagesSent = 0;
|
||||
int _sendspinMessagesReceived = 0;
|
||||
DateTime? _lastMessageReceived;
|
||||
int _totalReconnections = 0;
|
||||
|
||||
// State tracking
|
||||
RemoteBridgeState _state = RemoteBridgeState.disconnected;
|
||||
final _stateController = StreamController<RemoteBridgeState>.broadcast();
|
||||
final _errorController = StreamController<RemoteBridgeError>.broadcast();
|
||||
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
|
||||
int? get port => _port;
|
||||
bool get isRunning => _server != null;
|
||||
bool get isSendspinReady => _webrtcConnection?.isSendspinConnected ?? false;
|
||||
bool get isWebRTCConnected => _webrtcConnection?.isConnected ?? false;
|
||||
RemoteBridgeState get state => _state;
|
||||
Stream<RemoteBridgeState> get stateStream => _stateController.stream;
|
||||
Stream<RemoteBridgeError> get errorStream => _errorController.stream;
|
||||
|
||||
/// Build the hidden WebView widget that must be added to the widget tree.
|
||||
/// This must be called and the returned widget added to the tree BEFORE calling start().
|
||||
/// The WebView is 1x1 pixel and invisible.
|
||||
Widget buildWebView() {
|
||||
// Create the connection if not already created
|
||||
_webrtcConnection ??= WebViewWebRTCConnection();
|
||||
return _webrtcConnection!.buildWebView();
|
||||
}
|
||||
|
||||
/// Get current connection metrics
|
||||
ConnectionMetrics get metrics => ConnectionMetrics(
|
||||
requestsSent: _apiMessagesSent,
|
||||
responsesReceived: _apiMessagesReceived,
|
||||
reconnectionCount: _totalReconnections,
|
||||
lastActivity: _lastMessageReceived,
|
||||
);
|
||||
|
||||
/// Update state and notify listeners
|
||||
void _setState(RemoteBridgeState newState) {
|
||||
if (_state != newState) {
|
||||
_logger.log('RemoteBridge: State changed: $_state -> $newState');
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit an error to listeners
|
||||
void _emitError(RemoteBridgeErrorType type, String message) {
|
||||
final error = RemoteBridgeError(type: type, message: message);
|
||||
_logger.log('RemoteBridge: Error [$type]: $message');
|
||||
_errorController.add(error);
|
||||
}
|
||||
|
||||
/// Start the bridge and connect to the remote MA server.
|
||||
/// Returns the local port number to connect to, or null if failed.
|
||||
Future<int?> start(String remoteId, String username, String password) async {
|
||||
_logger.log('RemoteBridge: Starting bridge for remote ID: $remoteId');
|
||||
_setState(RemoteBridgeState.connecting);
|
||||
|
||||
// Store connection parameters for reconnection
|
||||
_remoteId = remoteId;
|
||||
_username = username;
|
||||
_password = password;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
try {
|
||||
// Step 1: Connect to MA via WebRTC (using WebView-based implementation)
|
||||
// The WebView must be in the widget tree first - call buildWebView() before start()
|
||||
_webrtcConnection ??= WebViewWebRTCConnection();
|
||||
|
||||
_webrtcConnection!.onStateChanged = (state) {
|
||||
_logger.log('RemoteBridge: WebRTC state: $state');
|
||||
if (state == WebViewWebRTCConnectionState.disconnected ||
|
||||
state == WebViewWebRTCConnectionState.failed) {
|
||||
_handleWebRTCDisconnection();
|
||||
}
|
||||
};
|
||||
|
||||
_webrtcConnection!.onError = (error) {
|
||||
_logger.log('RemoteBridge: WebRTC error: $error');
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, error);
|
||||
};
|
||||
|
||||
final connected = await _webrtcConnection!.connect(remoteId);
|
||||
if (!connected) {
|
||||
_logger.log('RemoteBridge: WebRTC connection failed');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Failed to connect to remote server');
|
||||
await stop();
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: WebRTC connected, authenticating...');
|
||||
_setState(RemoteBridgeState.authenticating);
|
||||
|
||||
// Step 2: Authenticate with MA
|
||||
final authenticated = await _authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
_logger.log('RemoteBridge: Authentication failed');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.authentication, 'Authentication failed');
|
||||
await stop();
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: Authentication successful');
|
||||
_setState(RemoteBridgeState.connected);
|
||||
|
||||
// Step 3: Cache the server hello (it was already received by WebRTCConnection)
|
||||
if (_webrtcConnection!.serverInfo != null) {
|
||||
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
|
||||
_logger.log('RemoteBridge: Cached server hello');
|
||||
}
|
||||
|
||||
// Step 4: Start local WebSocket server
|
||||
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_port = _server!.port;
|
||||
_logger.log('RemoteBridge: Local WS server started on port $_port');
|
||||
|
||||
// Handle incoming connections
|
||||
_server!.listen(_handleHttpRequest);
|
||||
|
||||
// Step 5: Subscribe to WebRTC API messages to forward to API client
|
||||
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
|
||||
_apiMessagesReceived++;
|
||||
_lastMessageReceived = DateTime.now();
|
||||
if (_apiClientSocket != null) {
|
||||
_apiClientSocket!.add(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 6: Subscribe to Sendspin WebRTC messages (text) to forward to Sendspin client
|
||||
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
|
||||
_sendspinMessagesReceived++;
|
||||
_lastMessageReceived = DateTime.now();
|
||||
if (_sendspinClientSocket != null) {
|
||||
_sendspinClientSocket!.add(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 7: Subscribe to Sendspin WebRTC messages (binary audio) to forward to Sendspin client
|
||||
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
|
||||
_sendspinMessagesReceived++;
|
||||
if (_sendspinClientSocket != null) {
|
||||
// Forward binary audio data to Sendspin client
|
||||
_sendspinClientSocket!.add(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 8: Start health check timer
|
||||
_startHealthCheck();
|
||||
|
||||
return _port;
|
||||
} catch (e) {
|
||||
_logger.log('RemoteBridge: Start failed: $e');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.unknown, e.toString());
|
||||
await stop();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate with the MA server over WebRTC.
|
||||
Future<bool> _authenticate(String username, String password) async {
|
||||
try {
|
||||
// Step 1: Login to get access token
|
||||
_logger.log('RemoteBridge: Sending auth/login...');
|
||||
final loginResult = await _webrtcConnection!.sendRequest('auth/login', {
|
||||
'username': username,
|
||||
'password': password,
|
||||
'device_name': 'Ensemble Mobile App',
|
||||
});
|
||||
|
||||
_logger.log('RemoteBridge: Login result: $loginResult');
|
||||
|
||||
// Check for error response
|
||||
if (loginResult.containsKey('error_code')) {
|
||||
_logger.log('RemoteBridge: Login error: ${loginResult['details']}');
|
||||
return false;
|
||||
}
|
||||
|
||||
String? accessToken;
|
||||
if (loginResult['result'] != null) {
|
||||
final result = loginResult['result'];
|
||||
if (result is Map<String, dynamic>) {
|
||||
if (result['success'] == true) {
|
||||
accessToken = result['access_token'] as String?;
|
||||
final user = result['user'] as Map<String, dynamic>?;
|
||||
_logger.log('RemoteBridge: Login successful! User: ${user?['username']}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accessToken == null) {
|
||||
_logger.log('RemoteBridge: No access token received');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Authenticate the session with the token
|
||||
_logger.log('RemoteBridge: Authenticating session with token...');
|
||||
final authResult = await _webrtcConnection!.sendRequest('auth', {
|
||||
'token': accessToken,
|
||||
});
|
||||
|
||||
_logger.log('RemoteBridge: Auth result: $authResult');
|
||||
|
||||
if (authResult.containsKey('error_code')) {
|
||||
_logger.log('RemoteBridge: Session auth failed: ${authResult['details']}');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: Session authenticated successfully!');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('RemoteBridge: Auth error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming HTTP requests and upgrade to WebSocket.
|
||||
void _handleHttpRequest(HttpRequest request) async {
|
||||
final path = request.uri.path;
|
||||
_logger.log('RemoteBridge: Incoming HTTP request: $path');
|
||||
|
||||
if (WebSocketTransformer.isUpgradeRequest(request)) {
|
||||
try {
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
|
||||
// Route based on path: /sendspin for audio, anything else for API
|
||||
if (path == '/sendspin') {
|
||||
_handleSendspinConnection(socket);
|
||||
} else {
|
||||
_handleApiConnection(socket);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('RemoteBridge: WebSocket upgrade failed: $e');
|
||||
request.response.statusCode = HttpStatus.internalServerError;
|
||||
await request.response.close();
|
||||
}
|
||||
} else {
|
||||
// Not a WebSocket request
|
||||
request.response.statusCode = HttpStatus.badRequest;
|
||||
await request.response.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a new API WebSocket client connection.
|
||||
void _handleApiConnection(WebSocket socket) {
|
||||
_logger.log('RemoteBridge: API client connected');
|
||||
|
||||
// Only allow one API client at a time
|
||||
if (_apiClientSocket != null) {
|
||||
_logger.log('RemoteBridge: Closing existing API client connection');
|
||||
_apiClientSubscription?.cancel();
|
||||
_apiClientSocket?.close();
|
||||
}
|
||||
|
||||
_apiClientSocket = socket;
|
||||
|
||||
// Step 1: Send cached server hello immediately
|
||||
if (_cachedServerHello != null) {
|
||||
_logger.log('RemoteBridge: Sending cached server hello to API client');
|
||||
_apiClientSocket!.add(_cachedServerHello!);
|
||||
}
|
||||
|
||||
// Step 2: Forward all messages from API client to WebRTC (ma-api channel)
|
||||
_apiClientSubscription = _apiClientSocket!.listen(
|
||||
(message) {
|
||||
if (message is String && _webrtcConnection != null) {
|
||||
_apiMessagesSent++;
|
||||
_webrtcConnection!.sendRaw(message);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_logger.log('RemoteBridge: API client disconnected');
|
||||
_apiClientSocket = null;
|
||||
_apiClientSubscription = null;
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.log('RemoteBridge: API client error: $error');
|
||||
_apiClientSocket = null;
|
||||
_apiClientSubscription = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle a new Sendspin WebSocket client connection (for audio streaming).
|
||||
void _handleSendspinConnection(WebSocket socket) {
|
||||
_logger.log('RemoteBridge: Sendspin client connected');
|
||||
|
||||
// Only allow one Sendspin client at a time
|
||||
if (_sendspinClientSocket != null) {
|
||||
_logger.log('RemoteBridge: Closing existing Sendspin client connection');
|
||||
_sendspinClientSubscription?.cancel();
|
||||
_sendspinClientSocket?.close();
|
||||
}
|
||||
|
||||
_sendspinClientSocket = socket;
|
||||
|
||||
// Forward all messages from Sendspin client to WebRTC (sendspin channel)
|
||||
_sendspinClientSubscription = _sendspinClientSocket!.listen(
|
||||
(message) {
|
||||
if (_webrtcConnection != null) {
|
||||
_sendspinMessagesSent++;
|
||||
if (message is String) {
|
||||
// Text message (JSON control)
|
||||
_webrtcConnection!.sendSendspinText(message);
|
||||
} else if (message is List<int>) {
|
||||
// Binary message (would be audio data from client, unlikely but handle it)
|
||||
_webrtcConnection!.sendSendspinBinary(Uint8List.fromList(message));
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_logger.log('RemoteBridge: Sendspin client disconnected');
|
||||
_sendspinClientSocket = null;
|
||||
_sendspinClientSubscription = null;
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.log('RemoteBridge: Sendspin client error: $error');
|
||||
_sendspinClientSocket = null;
|
||||
_sendspinClientSubscription = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle WebRTC disconnection - attempt reconnection.
|
||||
void _handleWebRTCDisconnection() {
|
||||
_logger.log('RemoteBridge: WebRTC disconnected');
|
||||
|
||||
// Don't close WS clients immediately - they will reconnect when WebRTC is back
|
||||
// Instead, attempt to reconnect WebRTC
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Schedule a WebRTC reconnection attempt.
|
||||
void _scheduleReconnect() {
|
||||
if (_isReconnecting) {
|
||||
_logger.log('RemoteBridge: Already reconnecting, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_remoteId == null || _username == null || _password == null) {
|
||||
_logger.log('RemoteBridge: No connection parameters, cannot reconnect');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Cannot reconnect - no connection parameters');
|
||||
_closeAllClients();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
_logger.log('RemoteBridge: Max reconnect attempts reached, giving up');
|
||||
_setState(RemoteBridgeState.failed);
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, 'Max reconnection attempts reached');
|
||||
_closeAllClients();
|
||||
return;
|
||||
}
|
||||
|
||||
_setState(RemoteBridgeState.reconnecting);
|
||||
_reconnectAttempts++;
|
||||
_totalReconnections++;
|
||||
|
||||
// Use longer delay if rate limited, otherwise exponential backoff
|
||||
final Duration delay;
|
||||
if (_isRateLimited) {
|
||||
delay = _rateLimitDelay;
|
||||
_logger.log('RemoteBridge: Rate limited - waiting ${delay.inSeconds}s before reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts');
|
||||
} else {
|
||||
delay = _reconnectDelay * _reconnectAttempts;
|
||||
_logger.log('RemoteBridge: Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s');
|
||||
}
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(delay, _reconnectWebRTC);
|
||||
}
|
||||
|
||||
/// Attempt to reconnect WebRTC.
|
||||
Future<void> _reconnectWebRTC() async {
|
||||
if (_isReconnecting) return;
|
||||
_isReconnecting = true;
|
||||
|
||||
_logger.log('RemoteBridge: Attempting WebRTC reconnection...');
|
||||
|
||||
try {
|
||||
// Clean up subscriptions but keep the WebView connection (it's still in the widget tree)
|
||||
_apiWebrtcSubscription?.cancel();
|
||||
_sendspinTextSubscription?.cancel();
|
||||
_sendspinBinarySubscription?.cancel();
|
||||
await _webrtcConnection?.disconnect();
|
||||
|
||||
// Reuse existing WebView connection (WebView is already in widget tree)
|
||||
// If somehow null, create a new one (shouldn't happen in normal flow)
|
||||
_webrtcConnection ??= WebViewWebRTCConnection();
|
||||
|
||||
_webrtcConnection!.onStateChanged = (state) {
|
||||
_logger.log('RemoteBridge: WebRTC state (reconnect): $state');
|
||||
if (state == WebViewWebRTCConnectionState.disconnected ||
|
||||
state == WebViewWebRTCConnectionState.failed) {
|
||||
_isReconnecting = false;
|
||||
_handleWebRTCDisconnection();
|
||||
} else if (state == WebViewWebRTCConnectionState.connected) {
|
||||
_reconnectAttempts = 0; // Reset on successful connection
|
||||
}
|
||||
};
|
||||
|
||||
_webrtcConnection!.onError = (error) {
|
||||
_logger.log('RemoteBridge: WebRTC error (reconnect): $error');
|
||||
// Detect rate limiting from signaling server
|
||||
if (error.toLowerCase().contains('rate limit')) {
|
||||
_logger.log('RemoteBridge: Rate limited - will wait ${_rateLimitDelay.inSeconds}s before retry');
|
||||
_isRateLimited = true;
|
||||
}
|
||||
};
|
||||
|
||||
final connected = await _webrtcConnection!.connect(_remoteId!);
|
||||
if (!connected) {
|
||||
_logger.log('RemoteBridge: WebRTC reconnection failed');
|
||||
_isReconnecting = false;
|
||||
_scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: WebRTC reconnected, re-authenticating...');
|
||||
|
||||
// Re-authenticate
|
||||
final authenticated = await _authenticate(_username!, _password!);
|
||||
if (!authenticated) {
|
||||
_logger.log('RemoteBridge: Re-authentication failed');
|
||||
_isReconnecting = false;
|
||||
_scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.log('RemoteBridge: Re-authentication successful');
|
||||
|
||||
// Re-cache server hello
|
||||
if (_webrtcConnection!.serverInfo != null) {
|
||||
_cachedServerHello = jsonEncode(_webrtcConnection!.serverInfo);
|
||||
_logger.log('RemoteBridge: Re-cached server hello');
|
||||
}
|
||||
|
||||
// Re-subscribe to WebRTC streams
|
||||
_apiWebrtcSubscription = _webrtcConnection!.rawMessages.listen((message) {
|
||||
_apiMessagesReceived++;
|
||||
_lastMessageReceived = DateTime.now();
|
||||
if (_apiClientSocket != null) {
|
||||
_apiClientSocket!.add(message);
|
||||
}
|
||||
});
|
||||
|
||||
_sendspinTextSubscription = _webrtcConnection!.sendspinTextMessages.listen((message) {
|
||||
_sendspinMessagesReceived++;
|
||||
_lastMessageReceived = DateTime.now();
|
||||
if (_sendspinClientSocket != null) {
|
||||
_sendspinClientSocket!.add(message);
|
||||
}
|
||||
});
|
||||
|
||||
_sendspinBinarySubscription = _webrtcConnection!.sendspinBinaryMessages.listen((data) {
|
||||
_sendspinMessagesReceived++;
|
||||
if (_sendspinClientSocket != null) {
|
||||
_sendspinClientSocket!.add(data);
|
||||
}
|
||||
});
|
||||
|
||||
_logger.log('RemoteBridge: ✅ WebRTC reconnection complete');
|
||||
_setState(RemoteBridgeState.connected);
|
||||
_isReconnecting = false;
|
||||
_reconnectAttempts = 0;
|
||||
_isRateLimited = false;
|
||||
} catch (e) {
|
||||
_logger.log('RemoteBridge: Reconnection error: $e');
|
||||
_emitError(RemoteBridgeErrorType.webrtcConnection, e.toString());
|
||||
_isReconnecting = false;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Close all WS clients (called when giving up on reconnection).
|
||||
void _closeAllClients() {
|
||||
_logger.log('RemoteBridge: Closing all WS clients');
|
||||
|
||||
_apiClientSocket?.close();
|
||||
_apiClientSocket = null;
|
||||
_apiClientSubscription?.cancel();
|
||||
_apiClientSubscription = null;
|
||||
|
||||
_sendspinClientSocket?.close();
|
||||
_sendspinClientSocket = null;
|
||||
_sendspinClientSubscription?.cancel();
|
||||
_sendspinClientSubscription = null;
|
||||
}
|
||||
|
||||
/// Start periodic health check to monitor connection state
|
||||
void _startHealthCheck() {
|
||||
_healthCheckTimer?.cancel();
|
||||
_healthCheckTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||
final dcState = _webrtcConnection?.dataChannelStateDebug ?? 'no connection';
|
||||
final sendspinState = _webrtcConnection?.isSendspinConnected ?? false;
|
||||
final wcState = _webrtcConnection?.state.toString() ?? 'no connection';
|
||||
final timeSinceLastMsg = _lastMessageReceived != null
|
||||
? DateTime.now().difference(_lastMessageReceived!).inSeconds
|
||||
: -1;
|
||||
_logger.log('RemoteBridge: Health check - API DC:$dcState, Sendspin:$sendspinState, WC:$wcState, '
|
||||
'API sent:$_apiMessagesSent recv:$_apiMessagesReceived, '
|
||||
'Sendspin sent:$_sendspinMessagesSent recv:$_sendspinMessagesReceived, '
|
||||
'lastRecv:${timeSinceLastMsg}s ago');
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop the bridge and disconnect from the remote server.
|
||||
Future<void> stop() async {
|
||||
_logger.log('RemoteBridge: Stopping bridge');
|
||||
_setState(RemoteBridgeState.disconnected);
|
||||
|
||||
// Cancel reconnection
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_isReconnecting = false;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
_healthCheckTimer?.cancel();
|
||||
_healthCheckTimer = null;
|
||||
|
||||
// Cancel API subscriptions
|
||||
_apiClientSubscription?.cancel();
|
||||
_apiClientSubscription = null;
|
||||
_apiWebrtcSubscription?.cancel();
|
||||
_apiWebrtcSubscription = null;
|
||||
|
||||
// Cancel Sendspin subscriptions
|
||||
_sendspinClientSubscription?.cancel();
|
||||
_sendspinClientSubscription = null;
|
||||
_sendspinTextSubscription?.cancel();
|
||||
_sendspinTextSubscription = null;
|
||||
_sendspinBinarySubscription?.cancel();
|
||||
_sendspinBinarySubscription = null;
|
||||
|
||||
// Close client sockets
|
||||
await _apiClientSocket?.close();
|
||||
_apiClientSocket = null;
|
||||
await _sendspinClientSocket?.close();
|
||||
_sendspinClientSocket = null;
|
||||
|
||||
await _server?.close(force: true);
|
||||
_server = null;
|
||||
_port = null;
|
||||
|
||||
await _webrtcConnection?.disconnect();
|
||||
_webrtcConnection = null;
|
||||
|
||||
_cachedServerHello = null;
|
||||
|
||||
// Clear connection parameters
|
||||
_remoteId = null;
|
||||
_username = null;
|
||||
_password = null;
|
||||
|
||||
_logger.log('RemoteBridge: Bridge stopped');
|
||||
}
|
||||
|
||||
/// Dispose the bridge and close all stream controllers.
|
||||
void dispose() {
|
||||
stop();
|
||||
_stateController.close();
|
||||
_errorController.close();
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:ensemble/services/remote/webrtc_connection.dart';
|
||||
import 'package:ensemble/services/debug_logger.dart';
|
||||
|
||||
/// Manages remote connections to MA servers via WebRTC
|
||||
class RemoteConnectionManager {
|
||||
final WebRTCConnection _connection = WebRTCConnection();
|
||||
|
||||
StreamSubscription? _messageSubscription;
|
||||
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
bool _isAuthenticated = false;
|
||||
String? _serverVersion;
|
||||
|
||||
// Callbacks
|
||||
Function(bool connected)? onConnectionChanged;
|
||||
Function(String error)? onError;
|
||||
|
||||
bool get isConnected => _connection.isConnected && _isAuthenticated;
|
||||
Stream<Map<String, dynamic>> get events => _eventController.stream;
|
||||
String? get serverVersion => _serverVersion;
|
||||
|
||||
RemoteConnectionManager() {
|
||||
_connection.onStateChanged = _onConnectionStateChanged;
|
||||
_connection.onError = (error) => onError?.call(error);
|
||||
|
||||
// Forward events from the data channel
|
||||
_messageSubscription = _connection.messages.listen((message) {
|
||||
// Check if this is an event (has 'event' key)
|
||||
if (message.containsKey('event')) {
|
||||
_eventController.add(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Connect to a remote MA server using Remote Access ID
|
||||
Future<bool> connect(String remoteId, String username, String password) async {
|
||||
DebugLogger().log('RemoteConnection: Connecting to remote ID: $remoteId');
|
||||
|
||||
// Connect WebRTC
|
||||
final connected = await _connection.connect(remoteId);
|
||||
if (!connected) {
|
||||
DebugLogger().log('RemoteConnection: WebRTC connection failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
DebugLogger().log('RemoteConnection: WebRTC connected, authenticating...');
|
||||
|
||||
// Authenticate with MA
|
||||
try {
|
||||
final authenticated = await _authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
DebugLogger().log('RemoteConnection: Authentication failed');
|
||||
await _connection.disconnect();
|
||||
return false;
|
||||
}
|
||||
|
||||
_isAuthenticated = true;
|
||||
DebugLogger().log('RemoteConnection: Fully connected and authenticated');
|
||||
onConnectionChanged?.call(true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
DebugLogger().log('RemoteConnection: Auth error: $e');
|
||||
await _connection.disconnect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate with the MA server
|
||||
Future<bool> _authenticate(String username, String password) async {
|
||||
try {
|
||||
// Step 1: Login to get access token
|
||||
DebugLogger().log('RemoteConnection: Sending auth/login...');
|
||||
final loginResult = await _connection.sendRequest('auth/login', {
|
||||
'username': username,
|
||||
'password': password,
|
||||
'device_name': 'Ensemble Mobile App',
|
||||
});
|
||||
|
||||
DebugLogger().log('RemoteConnection: Login result: $loginResult');
|
||||
|
||||
// Check for error response
|
||||
if (loginResult.containsKey('error_code')) {
|
||||
DebugLogger().log('RemoteConnection: Login error: ${loginResult['details']}');
|
||||
return false;
|
||||
}
|
||||
|
||||
String? accessToken;
|
||||
if (loginResult['result'] != null) {
|
||||
final result = loginResult['result'];
|
||||
|
||||
if (result is Map<String, dynamic>) {
|
||||
if (result['success'] == true) {
|
||||
accessToken = result['access_token'] as String?;
|
||||
final user = result['user'] as Map<String, dynamic>?;
|
||||
|
||||
DebugLogger().log('RemoteConnection: Login successful!');
|
||||
DebugLogger().log('RemoteConnection: User: ${user?['username']} (${user?['role']})');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accessToken == null) {
|
||||
DebugLogger().log('RemoteConnection: No access token received');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Authenticate the session with the token
|
||||
DebugLogger().log('RemoteConnection: Authenticating session with token...');
|
||||
final authResult = await _connection.sendRequest('auth', {
|
||||
'token': accessToken,
|
||||
});
|
||||
|
||||
DebugLogger().log('RemoteConnection: Auth result: $authResult');
|
||||
|
||||
if (authResult.containsKey('error_code')) {
|
||||
DebugLogger().log('RemoteConnection: Session auth failed: ${authResult['details']}');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store the token for later use
|
||||
_accessToken = accessToken;
|
||||
DebugLogger().log('RemoteConnection: Session authenticated successfully!');
|
||||
return true;
|
||||
} catch (e) {
|
||||
DebugLogger().log('RemoteConnection: Login error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String? _accessToken;
|
||||
|
||||
/// Send a command to the MA server
|
||||
Future<Map<String, dynamic>> sendCommand(
|
||||
String command, [
|
||||
Map<String, dynamic>? params,
|
||||
]) async {
|
||||
if (!_connection.isConnected) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
|
||||
return await _connection.sendRequest(command, params);
|
||||
}
|
||||
|
||||
/// Subscribe to events from the server
|
||||
Future<void> subscribe(List<String> eventTypes) async {
|
||||
await sendCommand('subscribe_events', {
|
||||
'event_types': eventTypes,
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect from the remote server
|
||||
Future<void> disconnect() async {
|
||||
_isAuthenticated = false;
|
||||
await _connection.disconnect();
|
||||
onConnectionChanged?.call(false);
|
||||
}
|
||||
|
||||
void _onConnectionStateChanged(WebRTCConnectionState state) {
|
||||
DebugLogger().log('RemoteConnection: State changed to $state');
|
||||
|
||||
if (state == WebRTCConnectionState.disconnected ||
|
||||
state == WebRTCConnectionState.failed) {
|
||||
_isAuthenticated = false;
|
||||
onConnectionChanged?.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_messageSubscription?.cancel();
|
||||
_eventController.close();
|
||||
_connection.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:ensemble/services/debug_logger.dart';
|
||||
|
||||
/// Message types for the MA signaling protocol
|
||||
class SignalingMessageType {
|
||||
static const String ping = 'ping';
|
||||
static const String pong = 'pong';
|
||||
static const String connectRequest = 'connect-request';
|
||||
static const String connected = 'connected';
|
||||
static const String offer = 'offer';
|
||||
static const String answer = 'answer';
|
||||
static const String iceCandidate = 'ice-candidate';
|
||||
static const String peerDisconnected = 'peer-disconnected';
|
||||
static const String error = 'error';
|
||||
}
|
||||
|
||||
/// ICE server configuration from signaling
|
||||
class IceServer {
|
||||
final List<String> urls;
|
||||
final String? username;
|
||||
final String? credential;
|
||||
|
||||
IceServer({
|
||||
required this.urls,
|
||||
this.username,
|
||||
this.credential,
|
||||
});
|
||||
|
||||
factory IceServer.fromJson(Map<String, dynamic> json) {
|
||||
// Handle urls as either String or List<String>
|
||||
final urlsRaw = json['urls'];
|
||||
final List<String> urls;
|
||||
if (urlsRaw is String) {
|
||||
urls = [urlsRaw];
|
||||
} else if (urlsRaw is List) {
|
||||
urls = urlsRaw.cast<String>();
|
||||
} else {
|
||||
urls = [];
|
||||
}
|
||||
|
||||
return IceServer(
|
||||
urls: urls,
|
||||
username: json['username'] as String?,
|
||||
credential: json['credential'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toWebRtcConfig() {
|
||||
final config = <String, dynamic>{'urls': urls};
|
||||
if (username != null) config['username'] = username;
|
||||
if (credential != null) config['credential'] = credential;
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
/// Client for connecting to the MA signaling server
|
||||
class SignalingClient {
|
||||
static const String _signalingUrl = 'wss://signaling.music-assistant.io/ws';
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _subscription;
|
||||
Timer? _pingTimer;
|
||||
|
||||
String? _sessionId;
|
||||
String? _remoteId;
|
||||
List<IceServer> _iceServers = [];
|
||||
bool _isConnected = false;
|
||||
|
||||
// Callbacks
|
||||
Function(String sessionId, List<IceServer> iceServers)? onConnected;
|
||||
Function(Map<String, dynamic> answer)? onAnswer;
|
||||
Function(Map<String, dynamic> candidate)? onIceCandidate;
|
||||
Function(String error)? onError;
|
||||
Function()? onPeerDisconnected;
|
||||
Function()? onDisconnected;
|
||||
|
||||
String? get sessionId => _sessionId;
|
||||
List<IceServer> get iceServers => _iceServers;
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Connect to the signaling server and request connection to a remote MA server
|
||||
Future<bool> connect(String remoteId) async {
|
||||
DebugLogger().log('Signaling: Connecting to $_signalingUrl');
|
||||
|
||||
try {
|
||||
// Normalize remote ID (remove dashes, uppercase)
|
||||
final normalizedId = remoteId.replaceAll('-', '').toUpperCase();
|
||||
_remoteId = normalizedId;
|
||||
DebugLogger().log('Signaling: Remote ID: $normalizedId');
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_signalingUrl));
|
||||
await _channel!.ready;
|
||||
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onError: (error) {
|
||||
DebugLogger().log('Signaling: WebSocket error: $error');
|
||||
onError?.call(error.toString());
|
||||
_cleanup();
|
||||
},
|
||||
onDone: () {
|
||||
DebugLogger().log('Signaling: WebSocket closed');
|
||||
onDisconnected?.call();
|
||||
_cleanup();
|
||||
},
|
||||
);
|
||||
|
||||
// Start ping timer to keep connection alive
|
||||
_startPingTimer();
|
||||
|
||||
// Send connect request
|
||||
_send({
|
||||
'type': SignalingMessageType.connectRequest,
|
||||
'remoteId': normalizedId,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
DebugLogger().log('Signaling: Failed to connect: $e');
|
||||
onError?.call(e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send WebRTC offer to the remote server
|
||||
void sendOffer(Map<String, dynamic> offer) {
|
||||
if (_sessionId == null || _remoteId == null) {
|
||||
DebugLogger().log('Signaling: Cannot send offer - no session');
|
||||
return;
|
||||
}
|
||||
|
||||
_send({
|
||||
'type': SignalingMessageType.offer,
|
||||
'remoteId': _remoteId,
|
||||
'sessionId': _sessionId,
|
||||
'data': offer,
|
||||
});
|
||||
}
|
||||
|
||||
/// Send ICE candidate to the remote server
|
||||
void sendIceCandidate(Map<String, dynamic> candidate) {
|
||||
if (_sessionId == null || _remoteId == null) {
|
||||
DebugLogger().log('Signaling: Cannot send ICE candidate - no session');
|
||||
return;
|
||||
}
|
||||
|
||||
_send({
|
||||
'type': SignalingMessageType.iceCandidate,
|
||||
'remoteId': _remoteId,
|
||||
'sessionId': _sessionId,
|
||||
'data': candidate,
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect from the signaling server
|
||||
void disconnect() {
|
||||
DebugLogger().log('Signaling: Disconnecting');
|
||||
_cleanup();
|
||||
}
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
try {
|
||||
final message = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
final type = message['type'] as String?;
|
||||
|
||||
DebugLogger().log('Signaling: Received message type: $type');
|
||||
|
||||
switch (type) {
|
||||
case SignalingMessageType.ping:
|
||||
// Server is pinging us - respond with pong to keep connection alive
|
||||
_send({'type': SignalingMessageType.pong});
|
||||
break;
|
||||
|
||||
case SignalingMessageType.pong:
|
||||
// Ignore pong responses to our pings
|
||||
break;
|
||||
|
||||
case SignalingMessageType.connected:
|
||||
_handleConnected(message);
|
||||
break;
|
||||
|
||||
case SignalingMessageType.answer:
|
||||
_handleAnswer(message);
|
||||
break;
|
||||
|
||||
case SignalingMessageType.iceCandidate:
|
||||
_handleIceCandidate(message);
|
||||
break;
|
||||
|
||||
case SignalingMessageType.peerDisconnected:
|
||||
DebugLogger().log('Signaling: Peer disconnected');
|
||||
onPeerDisconnected?.call();
|
||||
break;
|
||||
|
||||
case SignalingMessageType.error:
|
||||
final error = message['error'] as String? ?? 'Unknown error';
|
||||
DebugLogger().log('Signaling: Error from server: $error');
|
||||
onError?.call(error);
|
||||
break;
|
||||
|
||||
default:
|
||||
DebugLogger().log('Signaling: Unknown message type: $type');
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger().log('Signaling: Failed to handle message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnected(Map<String, dynamic> message) {
|
||||
_sessionId = message['sessionId'] as String?;
|
||||
_isConnected = true;
|
||||
|
||||
// Parse ICE servers
|
||||
final iceServersJson = message['iceServers'] as List<dynamic>?;
|
||||
if (iceServersJson != null) {
|
||||
_iceServers = iceServersJson
|
||||
.map((s) => IceServer.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
DebugLogger().log('Signaling: Connected with session $_sessionId');
|
||||
DebugLogger().log('Signaling: Received ${_iceServers.length} ICE servers');
|
||||
|
||||
onConnected?.call(_sessionId!, _iceServers);
|
||||
}
|
||||
|
||||
void _handleAnswer(Map<String, dynamic> message) {
|
||||
final answer = message['data'] as Map<String, dynamic>?;
|
||||
if (answer != null) {
|
||||
DebugLogger().log('Signaling: Received answer');
|
||||
onAnswer?.call(answer);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleIceCandidate(Map<String, dynamic> message) {
|
||||
final candidate = message['data'] as Map<String, dynamic>?;
|
||||
if (candidate != null) {
|
||||
DebugLogger().log('Signaling: Received ICE candidate');
|
||||
onIceCandidate?.call(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
void _send(Map<String, dynamic> message) {
|
||||
if (_channel == null) return;
|
||||
|
||||
final json = jsonEncode(message);
|
||||
DebugLogger().log('Signaling: Sending ${message['type']}');
|
||||
_channel!.sink.add(json);
|
||||
}
|
||||
|
||||
void _startPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
// Send ping every 15 seconds to keep connection alive
|
||||
_pingTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
_send({'type': SignalingMessageType.ping});
|
||||
});
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
_sessionId = null;
|
||||
_remoteId = null;
|
||||
_isConnected = false;
|
||||
}
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:ensemble/services/remote/signaling_client.dart';
|
||||
import 'package:ensemble/services/debug_logger.dart';
|
||||
|
||||
/// WebRTC connection state
|
||||
enum WebRTCConnectionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Manages a WebRTC peer connection for remote MA access
|
||||
class WebRTCConnection {
|
||||
RTCPeerConnection? _peerConnection;
|
||||
RTCDataChannel? _dataChannel;
|
||||
RTCDataChannel? _sendspinDataChannel;
|
||||
final SignalingClient _signalingClient = SignalingClient();
|
||||
final _uuid = const Uuid();
|
||||
|
||||
WebRTCConnectionState _state = WebRTCConnectionState.disconnected;
|
||||
Completer<bool>? _connectionCompleter;
|
||||
bool _serverHelloReceived = false;
|
||||
Completer<void>? _serverHelloCompleter;
|
||||
|
||||
// Message handling for MA API
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _rawMessageController = StreamController<String>.broadcast();
|
||||
final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
|
||||
|
||||
// Sendspin message handling (text and binary)
|
||||
final _sendspinTextController = StreamController<String>.broadcast();
|
||||
final _sendspinBinaryController = StreamController<Uint8List>.broadcast();
|
||||
|
||||
// Server info
|
||||
Map<String, dynamic>? serverInfo;
|
||||
|
||||
// Callbacks
|
||||
Function(WebRTCConnectionState state)? onStateChanged;
|
||||
Function(String error)? onError;
|
||||
|
||||
WebRTCConnectionState get state => _state;
|
||||
Stream<Map<String, dynamic>> get messages => _messageController.stream;
|
||||
Stream<String> get rawMessages => _rawMessageController.stream;
|
||||
bool get isConnected => _state == WebRTCConnectionState.connected;
|
||||
|
||||
// Sendspin channel streams
|
||||
Stream<String> get sendspinTextMessages => _sendspinTextController.stream;
|
||||
Stream<Uint8List> get sendspinBinaryMessages => _sendspinBinaryController.stream;
|
||||
bool get isSendspinConnected =>
|
||||
_sendspinDataChannel?.state == RTCDataChannelState.RTCDataChannelOpen;
|
||||
|
||||
/// Connect to a remote MA server using the Remote Access ID
|
||||
Future<bool> connect(String remoteId) async {
|
||||
if (_state == WebRTCConnectionState.connecting) {
|
||||
DebugLogger().log('WebRTC: Already connecting');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setState(WebRTCConnectionState.connecting);
|
||||
_connectionCompleter = Completer<bool>();
|
||||
|
||||
try {
|
||||
// Set up signaling callbacks
|
||||
_signalingClient.onConnected = _onSignalingConnected;
|
||||
_signalingClient.onAnswer = _onAnswer;
|
||||
_signalingClient.onIceCandidate = _onRemoteIceCandidate;
|
||||
_signalingClient.onError = _onSignalingError;
|
||||
_signalingClient.onPeerDisconnected = _onPeerDisconnected;
|
||||
_signalingClient.onDisconnected = _onSignalingDisconnected;
|
||||
|
||||
// Connect to signaling server
|
||||
final connected = await _signalingClient.connect(remoteId);
|
||||
if (!connected) {
|
||||
_setState(WebRTCConnectionState.failed);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for connection to complete (with timeout)
|
||||
final result = await _connectionCompleter!.future.timeout(
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () {
|
||||
DebugLogger().log('WebRTC: Connection timeout');
|
||||
_onSignalingError('Connection timeout');
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Connection error: $e');
|
||||
_setState(WebRTCConnectionState.failed);
|
||||
onError?.call(e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC request and wait for response
|
||||
Future<Map<String, dynamic>> sendRequest(
|
||||
String command, [
|
||||
Map<String, dynamic>? params,
|
||||
]) async {
|
||||
if (_dataChannel == null || _dataChannel!.state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||
throw Exception('Data channel not open');
|
||||
}
|
||||
|
||||
final messageId = _uuid.v4();
|
||||
final request = {
|
||||
'message_id': messageId,
|
||||
'command': command,
|
||||
if (params != null) 'args': params,
|
||||
};
|
||||
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
_pendingRequests[messageId] = completer;
|
||||
|
||||
final jsonStr = jsonEncode(request);
|
||||
DebugLogger().log('WebRTC: Sending request: $jsonStr');
|
||||
_dataChannel!.send(RTCDataChannelMessage(jsonStr));
|
||||
|
||||
// Timeout after 30 seconds
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () {
|
||||
_pendingRequests.remove(messageId);
|
||||
throw TimeoutException('Request timed out: $command');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Send a raw message without waiting for response (for bridge passthrough)
|
||||
void sendRaw(String message) {
|
||||
if (_dataChannel == null) {
|
||||
DebugLogger().log('WebRTC: Cannot send raw - data channel is null');
|
||||
return;
|
||||
}
|
||||
|
||||
final state = _dataChannel!.state;
|
||||
if (state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||
DebugLogger().log('WebRTC: Cannot send raw - data channel state: $state');
|
||||
return;
|
||||
}
|
||||
|
||||
DebugLogger().log('WebRTC: Sending raw: ${message.substring(0, message.length > 100 ? 100 : message.length)}...');
|
||||
_dataChannel!.send(RTCDataChannelMessage(message));
|
||||
}
|
||||
|
||||
/// Check if the data channel is healthy
|
||||
bool get isDataChannelHealthy {
|
||||
if (_dataChannel == null) return false;
|
||||
return _dataChannel!.state == RTCDataChannelState.RTCDataChannelOpen;
|
||||
}
|
||||
|
||||
/// Get data channel state for debugging
|
||||
String get dataChannelStateDebug {
|
||||
if (_dataChannel == null) return 'null';
|
||||
return _dataChannel!.state.toString();
|
||||
}
|
||||
|
||||
/// Disconnect from the remote server
|
||||
Future<void> disconnect() async {
|
||||
DebugLogger().log('WebRTC: Disconnecting');
|
||||
|
||||
_signalingClient.disconnect();
|
||||
await _dataChannel?.close();
|
||||
await _sendspinDataChannel?.close();
|
||||
await _peerConnection?.close();
|
||||
|
||||
_dataChannel = null;
|
||||
_sendspinDataChannel = null;
|
||||
_peerConnection = null;
|
||||
|
||||
// Complete all pending requests with an error before clearing
|
||||
_completePendingRequestsWithError('WebRTC connection disconnected');
|
||||
|
||||
_serverHelloReceived = false;
|
||||
_serverHelloCompleter = null;
|
||||
serverInfo = null;
|
||||
|
||||
_setState(WebRTCConnectionState.disconnected);
|
||||
}
|
||||
|
||||
/// Complete all pending requests with an error message
|
||||
void _completePendingRequestsWithError(String reason) {
|
||||
for (final entry in _pendingRequests.entries) {
|
||||
if (!entry.value.isCompleted) {
|
||||
entry.value.completeError(Exception(reason));
|
||||
}
|
||||
}
|
||||
_pendingRequests.clear();
|
||||
}
|
||||
|
||||
void _setState(WebRTCConnectionState newState) {
|
||||
if (_state != newState) {
|
||||
DebugLogger().log('WebRTC: State changed: $_state -> $newState');
|
||||
_state = newState;
|
||||
onStateChanged?.call(newState);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSignalingConnected(String sessionId, List<IceServer> iceServers) async {
|
||||
DebugLogger().log('WebRTC: Signaling connected, creating peer connection');
|
||||
|
||||
try {
|
||||
// Configure Android audio to NOT manage focus since we only use data channels
|
||||
// This prevents flutter_webrtc from interfering with system audio/speakers
|
||||
await AndroidNativeAudioManagement.setAndroidAudioConfiguration(
|
||||
AndroidAudioConfiguration(
|
||||
manageAudioFocus: false,
|
||||
),
|
||||
);
|
||||
DebugLogger().log('WebRTC: Android audio configured - manageAudioFocus: false');
|
||||
|
||||
// Create peer connection with ICE servers
|
||||
final config = <String, dynamic>{
|
||||
'iceServers': iceServers.map((s) => s.toWebRtcConfig()).toList(),
|
||||
'sdpSemantics': 'unified-plan',
|
||||
};
|
||||
|
||||
DebugLogger().log('WebRTC: ICE servers: ${config['iceServers']}');
|
||||
|
||||
_peerConnection = await createPeerConnection(config);
|
||||
|
||||
// Set up peer connection callbacks
|
||||
_peerConnection!.onIceCandidate = _onLocalIceCandidate;
|
||||
_peerConnection!.onIceConnectionState = _onIceConnectionState;
|
||||
_peerConnection!.onConnectionState = _onConnectionState;
|
||||
_peerConnection!.onDataChannel = _onDataChannel;
|
||||
|
||||
// Create data channel for MA API
|
||||
final dataChannelInit = RTCDataChannelInit()
|
||||
..ordered = true
|
||||
..protocol = 'ma-api';
|
||||
|
||||
_dataChannel = await _peerConnection!.createDataChannel('ma-api', dataChannelInit);
|
||||
_setupDataChannel(_dataChannel!);
|
||||
|
||||
// TEST: Sendspin channel DISABLED to test if multi-channel bug causes disconnects
|
||||
// If stability improves, the flutter_webrtc multi-channel bug is confirmed
|
||||
DebugLogger().log('WebRTC: ⚠️ TEST BUILD - Sendspin channel DISABLED (single channel mode)');
|
||||
// final sendspinChannelInit = RTCDataChannelInit()
|
||||
// ..ordered = true
|
||||
// ..protocol = 'sendspin';
|
||||
// _sendspinDataChannel = await _peerConnection!.createDataChannel('sendspin', sendspinChannelInit);
|
||||
// _setupSendspinDataChannel(_sendspinDataChannel!);
|
||||
|
||||
// Create and send offer
|
||||
final offer = await _peerConnection!.createOffer();
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
DebugLogger().log('WebRTC: Sending offer');
|
||||
_signalingClient.sendOffer({
|
||||
'type': offer.type,
|
||||
'sdp': offer.sdp,
|
||||
});
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Failed to create peer connection: $e');
|
||||
_onSignalingError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAnswer(Map<String, dynamic> answer) async {
|
||||
DebugLogger().log('WebRTC: Received answer');
|
||||
|
||||
try {
|
||||
final description = RTCSessionDescription(
|
||||
answer['sdp'] as String?,
|
||||
answer['type'] as String?,
|
||||
);
|
||||
await _peerConnection?.setRemoteDescription(description);
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Failed to set remote description: $e');
|
||||
_onSignalingError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _onLocalIceCandidate(RTCIceCandidate candidate) {
|
||||
DebugLogger().log('WebRTC: Local ICE candidate: ${candidate.candidate?.substring(0, 50)}...');
|
||||
|
||||
_signalingClient.sendIceCandidate({
|
||||
'candidate': candidate.candidate,
|
||||
'sdpMid': candidate.sdpMid,
|
||||
'sdpMLineIndex': candidate.sdpMLineIndex,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onRemoteIceCandidate(Map<String, dynamic> candidateData) async {
|
||||
DebugLogger().log('WebRTC: Remote ICE candidate received');
|
||||
|
||||
try {
|
||||
final candidate = RTCIceCandidate(
|
||||
candidateData['candidate'] as String?,
|
||||
candidateData['sdpMid'] as String?,
|
||||
candidateData['sdpMLineIndex'] as int?,
|
||||
);
|
||||
await _peerConnection?.addCandidate(candidate);
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Failed to add ICE candidate: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onIceConnectionState(RTCIceConnectionState state) {
|
||||
DebugLogger().log('WebRTC: ICE connection state: $state');
|
||||
|
||||
switch (state) {
|
||||
case RTCIceConnectionState.RTCIceConnectionStateFailed:
|
||||
_onSignalingError('ICE connection failed');
|
||||
break;
|
||||
case RTCIceConnectionState.RTCIceConnectionStateDisconnected:
|
||||
DebugLogger().log('WebRTC: ICE disconnected, may recover');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onConnectionState(RTCPeerConnectionState state) {
|
||||
DebugLogger().log('WebRTC: Peer connection state: $state');
|
||||
|
||||
switch (state) {
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
|
||||
// Connection established, but wait for data channel
|
||||
break;
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
|
||||
_onSignalingError('Peer connection failed');
|
||||
break;
|
||||
case RTCPeerConnectionState.RTCPeerConnectionStateClosed:
|
||||
_setState(WebRTCConnectionState.disconnected);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDataChannel(RTCDataChannel channel) {
|
||||
DebugLogger().log('WebRTC: Remote data channel received: ${channel.label}');
|
||||
if (channel.label == 'ma-api') {
|
||||
_dataChannel = channel;
|
||||
_setupDataChannel(channel);
|
||||
} else if (channel.label == 'sendspin') {
|
||||
_sendspinDataChannel = channel;
|
||||
_setupSendspinDataChannel(channel);
|
||||
}
|
||||
}
|
||||
|
||||
void _setupDataChannel(RTCDataChannel channel) {
|
||||
channel.onDataChannelState = (state) {
|
||||
DebugLogger().log('WebRTC: Data channel state: $state');
|
||||
|
||||
if (state == RTCDataChannelState.RTCDataChannelOpen) {
|
||||
DebugLogger().log('WebRTC: Data channel open - waiting for server hello...');
|
||||
// Don't complete yet - wait for server hello
|
||||
_serverHelloCompleter = Completer<void>();
|
||||
_serverHelloCompleter!.future.then((_) {
|
||||
DebugLogger().log('WebRTC: Server hello received - connection complete!');
|
||||
_setState(WebRTCConnectionState.connected);
|
||||
_safeCompleteConnection(true);
|
||||
});
|
||||
} else if (state == RTCDataChannelState.RTCDataChannelClosed) {
|
||||
DebugLogger().log('WebRTC: Data channel closed, completing pending requests with error');
|
||||
_completePendingRequestsWithError('Data channel closed');
|
||||
_setState(WebRTCConnectionState.disconnected);
|
||||
}
|
||||
};
|
||||
|
||||
channel.onMessage = _onDataChannelMessage;
|
||||
}
|
||||
|
||||
void _onDataChannelMessage(RTCDataChannelMessage message) {
|
||||
try {
|
||||
DebugLogger().log('WebRTC: Raw message: ${message.text.substring(0, message.text.length > 200 ? 200 : message.text.length)}...');
|
||||
final data = jsonDecode(message.text) as Map<String, dynamic>;
|
||||
final messageId = data['message_id']?.toString();
|
||||
|
||||
DebugLogger().log('WebRTC: Parsed message keys: ${data.keys.toList()}, message_id: $messageId');
|
||||
|
||||
// Check for server hello message (has server_id but no message_id)
|
||||
if (data.containsKey('server_id') && messageId == null && !_serverHelloReceived) {
|
||||
DebugLogger().log('WebRTC: Server hello received - server_version: ${data['server_version']}');
|
||||
serverInfo = data;
|
||||
_serverHelloReceived = true;
|
||||
_serverHelloCompleter?.complete();
|
||||
// Don't forward server hello to rawMessages - bridge caches it separately
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a response to a pending request (for internal use like auth)
|
||||
if (messageId != null && _pendingRequests.containsKey(messageId)) {
|
||||
DebugLogger().log('WebRTC: Matched pending request $messageId');
|
||||
_pendingRequests.remove(messageId)?.complete(data);
|
||||
// Don't forward to rawMessages - this was an internal request
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward all other messages to rawMessages stream (for bridge passthrough)
|
||||
_rawMessageController.add(message.text);
|
||||
|
||||
// Also broadcast parsed event/notification for internal listeners
|
||||
DebugLogger().log('WebRTC: No match for message_id=$messageId, pending: ${_pendingRequests.keys.toList()}');
|
||||
_messageController.add(data);
|
||||
} catch (e) {
|
||||
DebugLogger().log('WebRTC: Failed to parse message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup Sendspin data channel for audio streaming
|
||||
void _setupSendspinDataChannel(RTCDataChannel channel) {
|
||||
channel.onDataChannelState = (state) {
|
||||
DebugLogger().log('WebRTC: Sendspin data channel state: $state');
|
||||
};
|
||||
|
||||
channel.onMessage = _onSendspinMessage;
|
||||
}
|
||||
|
||||
/// Handle incoming Sendspin messages (both text and binary)
|
||||
void _onSendspinMessage(RTCDataChannelMessage message) {
|
||||
if (message.isBinary) {
|
||||
// Binary audio data
|
||||
final bytes = message.binary;
|
||||
if (bytes != null && !_sendspinBinaryController.isClosed) {
|
||||
_sendspinBinaryController.add(bytes);
|
||||
}
|
||||
} else {
|
||||
// Text control message (JSON)
|
||||
final text = message.text;
|
||||
DebugLogger().log('WebRTC: Sendspin text message: ${text.substring(0, text.length > 100 ? 100 : text.length)}...');
|
||||
if (!_sendspinTextController.isClosed) {
|
||||
_sendspinTextController.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a text message to Sendspin channel (JSON control messages)
|
||||
void sendSendspinText(String message) {
|
||||
if (_sendspinDataChannel == null) {
|
||||
DebugLogger().log('WebRTC: Cannot send sendspin text - channel is null');
|
||||
return;
|
||||
}
|
||||
|
||||
final state = _sendspinDataChannel!.state;
|
||||
if (state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||
DebugLogger().log('WebRTC: Cannot send sendspin text - channel state: $state');
|
||||
return;
|
||||
}
|
||||
|
||||
_sendspinDataChannel!.send(RTCDataChannelMessage(message));
|
||||
}
|
||||
|
||||
/// Send binary data to Sendspin channel (audio data from local player)
|
||||
void sendSendspinBinary(Uint8List data) {
|
||||
if (_sendspinDataChannel == null) {
|
||||
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel is null');
|
||||
return;
|
||||
}
|
||||
|
||||
final state = _sendspinDataChannel!.state;
|
||||
if (state != RTCDataChannelState.RTCDataChannelOpen) {
|
||||
DebugLogger().log('WebRTC: Cannot send sendspin binary - channel state: $state');
|
||||
return;
|
||||
}
|
||||
|
||||
_sendspinDataChannel!.send(RTCDataChannelMessage.fromBinary(data));
|
||||
}
|
||||
|
||||
void _onSignalingError(String error) {
|
||||
DebugLogger().log('WebRTC: Signaling error: $error');
|
||||
_setState(WebRTCConnectionState.failed);
|
||||
onError?.call(error);
|
||||
_safeCompleteConnection(false);
|
||||
}
|
||||
|
||||
void _onPeerDisconnected() {
|
||||
DebugLogger().log('WebRTC: Peer disconnected, completing pending requests with error');
|
||||
_completePendingRequestsWithError('Remote peer disconnected');
|
||||
_setState(WebRTCConnectionState.disconnected);
|
||||
onError?.call('Remote server disconnected');
|
||||
}
|
||||
|
||||
void _onSignalingDisconnected() {
|
||||
DebugLogger().log('WebRTC: Signaling disconnected');
|
||||
// Only set failed if we were still connecting
|
||||
if (_state == WebRTCConnectionState.connecting) {
|
||||
_setState(WebRTCConnectionState.failed);
|
||||
_safeCompleteConnection(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Safely complete the connection completer (only once)
|
||||
void _safeCompleteConnection(bool success) {
|
||||
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||
_connectionCompleter!.complete(success);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_messageController.close();
|
||||
_rawMessageController.close();
|
||||
_sendspinTextController.close();
|
||||
_sendspinBinaryController.close();
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||