Compare commits
16
Commits
v3.0.4-beta
...
stash-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fc24539b1 | ||
|
|
32f62a4731 | ||
|
|
9b8f6d36c6 | ||
|
|
a3e414dc4b | ||
|
|
a6d6eb8d55 | ||
|
|
cd8c5a66e4 | ||
|
|
dadfe65e5a | ||
|
|
0a853bd862 | ||
|
|
694b7127a0 | ||
|
|
4cbfd6a9e0 | ||
|
|
8bf0d23972 | ||
|
|
c17b8b5c5e | ||
|
|
4e3134df9c | ||
|
|
c4b80b9d33 | ||
|
|
d28c6ab556 | ||
|
|
ccfea4168c |
@@ -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
|
||||||
@@ -8,6 +8,9 @@ class Timings {
|
|||||||
/// Player state polling interval (for selected player updates)
|
/// Player state polling interval (for selected player updates)
|
||||||
static const Duration playerPollingInterval = Duration(seconds: 5);
|
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
|
/// Local player state report interval
|
||||||
/// Note: Sendspin protocol only requires state on changes, not periodic reports.
|
/// 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.
|
/// 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
|
/// Default limit for recent/random items
|
||||||
static const int defaultRecentLimit = 10;
|
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';
|
||||||
|
}
|
||||||
|
|||||||
+211
-55
@@ -6,14 +6,25 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
import 'providers/connection_provider.dart';
|
||||||
|
import 'providers/library_provider.dart';
|
||||||
import 'providers/locale_provider.dart';
|
import 'providers/locale_provider.dart';
|
||||||
|
import 'providers/local_player_provider.dart';
|
||||||
import 'providers/music_assistant_provider.dart';
|
import 'providers/music_assistant_provider.dart';
|
||||||
import 'providers/navigation_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/home_screen.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
|
import 'services/cache_service.dart';
|
||||||
import 'services/settings_service.dart';
|
import 'services/settings_service.dart';
|
||||||
import 'services/database_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/profile_service.dart';
|
||||||
|
import 'services/provider_filter_service.dart';
|
||||||
import 'services/sync_service.dart';
|
import 'services/sync_service.dart';
|
||||||
import 'services/audio/massiv_audio_handler.dart';
|
import 'services/audio/massiv_audio_handler.dart';
|
||||||
import 'services/auth/auth_manager.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/theme_provider.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'theme/system_theme_helper.dart';
|
import 'theme/system_theme_helper.dart';
|
||||||
|
import 'utils/player_sync_state.dart';
|
||||||
import 'widgets/global_player_overlay.dart';
|
import 'widgets/global_player_overlay.dart';
|
||||||
|
|
||||||
// Global audio handler instance
|
// Global audio handler instance
|
||||||
@@ -125,6 +137,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
late MusicAssistantProvider _musicProvider;
|
late MusicAssistantProvider _musicProvider;
|
||||||
late ThemeProvider _themeProvider;
|
late ThemeProvider _themeProvider;
|
||||||
late LocaleProvider _localeProvider;
|
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();
|
final _hardwareVolumeService = HardwareVolumeService();
|
||||||
StreamSubscription? _volumeUpSub;
|
StreamSubscription? _volumeUpSub;
|
||||||
StreamSubscription? _volumeDownSub;
|
StreamSubscription? _volumeDownSub;
|
||||||
@@ -137,9 +164,91 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_musicProvider = MusicAssistantProvider();
|
|
||||||
_themeProvider = ThemeProvider();
|
_themeProvider = ThemeProvider();
|
||||||
_localeProvider = LocaleProvider();
|
_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);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_initHardwareVolumeControl();
|
_initHardwareVolumeControl();
|
||||||
// Listen to player selection changes to toggle volume interception
|
// Listen to player selection changes to toggle volume interception
|
||||||
@@ -252,9 +361,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
|
// Core facade provider (will be removed after migration)
|
||||||
ChangeNotifierProvider.value(value: _musicProvider),
|
ChangeNotifierProvider.value(value: _musicProvider),
|
||||||
|
|
||||||
|
// Theme providers
|
||||||
ChangeNotifierProvider.value(value: _themeProvider),
|
ChangeNotifierProvider.value(value: _themeProvider),
|
||||||
ChangeNotifierProvider.value(value: _localeProvider),
|
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>(
|
child: Consumer2<ThemeProvider, LocaleProvider>(
|
||||||
builder: (context, themeProvider, localeProvider, _) {
|
builder: (context, themeProvider, localeProvider, _) {
|
||||||
@@ -329,8 +450,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// Wrap entire app with global player overlay
|
// Wrap entire app with global player overlay.
|
||||||
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
|
||||||
|
return Overlay(
|
||||||
|
initialEntries: [
|
||||||
|
OverlayEntry(
|
||||||
|
builder: (context) => GlobalPlayerOverlay(
|
||||||
|
child: child ?? const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
home: const AppStartup(),
|
home: const AppStartup(),
|
||||||
),
|
),
|
||||||
@@ -450,94 +580,120 @@ class _AppStartupState extends State<AppStartup> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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.
|
/// Load settings BEFORE checking connection.
|
||||||
/// This ensures we know if this is a first-time user before transitioning.
|
/// This ensures we know if this is a first-time user before transitioning.
|
||||||
Future<void> _loadSettingsFirst() async {
|
Future<void> _loadSettingsFirst() async {
|
||||||
// Load onboarding state first - this is critical for determining
|
try {
|
||||||
// when to transition to HomeScreen
|
// Load onboarding state first - this is critical for determining
|
||||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
// when to transition to HomeScreen
|
||||||
|
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_settingsLoaded = true;
|
_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
|
// Now check connection
|
||||||
_checkAndConnect();
|
_checkAndConnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkAndConnect() async {
|
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(() {
|
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()
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
// Just wait for it to complete or timeout
|
|
||||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
|
||||||
|
|
||||||
// For first-time users, we need to wait for both connection AND player selection
|
// Connection is handled by MusicAssistantProvider._initialize()
|
||||||
// so the welcome overlay can appear immediately without home screen flash.
|
// Just wait for it to complete or timeout
|
||||||
// For returning users, just wait for connection.
|
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||||
final needsWelcome = !_hasCompletedOnboarding;
|
|
||||||
|
|
||||||
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
// For first-time users, we need to wait for both connection AND player selection
|
||||||
// Check connection state periodically
|
// so the welcome overlay can appear immediately without home screen flash.
|
||||||
for (var i = 0; i < 20; i++) {
|
// For returning users, just wait for connection.
|
||||||
await Future.delayed(const Duration(milliseconds: 250));
|
final needsWelcome = !_hasCompletedOnboarding;
|
||||||
|
|
||||||
if (needsWelcome) {
|
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||||
// First-time user: wait for connection + player selection
|
// Check connection state periodically
|
||||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
for (var i = 0; i < 20; i++) {
|
||||||
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
await Future.delayed(const Duration(milliseconds: 250));
|
||||||
break;
|
|
||||||
|
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.connectionState == MAConnectionState.error) {
|
||||||
if (provider.isConnected) {
|
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||||
_logger.log('🚀 AppStartup: Connection established');
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.connectionState == MAConnectionState.error) {
|
if (!provider.isConnected) {
|
||||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
||||||
break;
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isConnecting = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
if (!provider.isConnected) {
|
_logger.error('AppStartup connection error: $e');
|
||||||
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
_connectionAttempted = true;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
_connectionAttempted = true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
_connectionAttempted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ import '../services/cache_service.dart';
|
|||||||
import '../services/recently_played_service.dart';
|
import '../services/recently_played_service.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
import '../services/local_player_service.dart';
|
import '../services/local_player_service.dart';
|
||||||
import '../services/metadata_service.dart';
|
|
||||||
import '../services/position_tracker.dart';
|
import '../services/position_tracker.dart';
|
||||||
import '../services/sendspin_service.dart';
|
import '../services/sendspin_service.dart';
|
||||||
import '../services/pcm_audio_player.dart';
|
import '../services/pcm_audio_player.dart';
|
||||||
@@ -28,7 +27,18 @@ import '../services/offline_action_queue.dart';
|
|||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../services/database_service.dart';
|
import '../services/database_service.dart';
|
||||||
import '../services/library_status_service.dart';
|
import '../services/library_status_service.dart';
|
||||||
|
import '../services/image_helper_service.dart';
|
||||||
|
import '../services/provider_filter_service.dart';
|
||||||
|
import '../services/queue_manager_service.dart';
|
||||||
|
import '../services/search_service.dart';
|
||||||
|
import '../utils/network_utils.dart';
|
||||||
|
import '../utils/player_sort_utility.dart';
|
||||||
|
import '../utils/queue_controls.dart';
|
||||||
|
import '../utils/player_sync_state.dart';
|
||||||
import '../main.dart' show audioHandler;
|
import '../main.dart' show audioHandler;
|
||||||
|
import 'connection_provider.dart';
|
||||||
|
import 'library_provider.dart';
|
||||||
|
import 'player_provider.dart';
|
||||||
|
|
||||||
/// Main provider that coordinates connection, player, and library state.
|
/// Main provider that coordinates connection, player, and library state.
|
||||||
///
|
///
|
||||||
@@ -41,13 +51,19 @@ import '../main.dart' show audioHandler;
|
|||||||
/// - Player logic: Player selection, controls, local player
|
/// - Player logic: Player selection, controls, local player
|
||||||
/// - Library logic: Artists, albums, tracks, search
|
/// - Library logic: Artists, albums, tracks, search
|
||||||
class MusicAssistantProvider with ChangeNotifier {
|
class MusicAssistantProvider with ChangeNotifier {
|
||||||
|
// Sub-providers (injected for delegation)
|
||||||
|
final ConnectionProvider? _connectionProvider;
|
||||||
|
final LibraryProvider? _libraryProvider;
|
||||||
|
final PlayerProvider? _playerProvider;
|
||||||
|
|
||||||
MusicAssistantAPI? _api;
|
MusicAssistantAPI? _api;
|
||||||
final AuthManager _authManager = AuthManager();
|
final AuthManager _authManager = AuthManager();
|
||||||
final DebugLogger _logger = DebugLogger();
|
final DebugLogger _logger = DebugLogger();
|
||||||
final CacheService _cacheService = CacheService();
|
final CacheService _cacheService = CacheService();
|
||||||
final PositionTracker _positionTracker = PositionTracker();
|
final PositionTracker _positionTracker = PositionTracker();
|
||||||
|
|
||||||
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
// Local state (used when _connectionProvider is null, for backward compatibility)
|
||||||
|
MAConnectionState? _connectionState;
|
||||||
String? _serverUrl;
|
String? _serverUrl;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@@ -85,7 +101,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Local player state
|
// Local player state
|
||||||
bool _isLocalPlayerPowered = true;
|
bool _isLocalPlayerPowered = true;
|
||||||
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
int _localPlayerVolume = VolumeConstants.max; // Tracked MA volume for builtin player (0-100)
|
||||||
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
|
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
|
||||||
|
|
||||||
// Home refresh counter - increments to signal home screen to refresh all rows
|
// Home refresh counter - increments to signal home screen to refresh all rows
|
||||||
@@ -111,6 +127,44 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// PCM audio player for raw Sendspin audio streaming
|
// PCM audio player for raw Sendspin audio streaming
|
||||||
PcmAudioPlayer? _pcmAudioPlayer;
|
PcmAudioPlayer? _pcmAudioPlayer;
|
||||||
|
|
||||||
|
// Image helper service for artwork URL generation
|
||||||
|
ImageHelperService? _imageHelper;
|
||||||
|
|
||||||
|
// Provider filter service (created on demand)
|
||||||
|
ProviderFilterService get _providerFilterService => ProviderFilterService(
|
||||||
|
providerFilter: _providerFilter,
|
||||||
|
playerFilter: _playerFilter,
|
||||||
|
availableMusicProviders: _availableMusicProviders,
|
||||||
|
artists: _artists,
|
||||||
|
albums: _albums,
|
||||||
|
tracks: _tracks,
|
||||||
|
podcasts: _podcasts,
|
||||||
|
radioStations: _radioStations,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Player sync state utility (created on demand)
|
||||||
|
PlayerSyncState get _playerSyncState => PlayerSyncState(
|
||||||
|
availablePlayers: _availablePlayers,
|
||||||
|
castToSendspinIdMap: _castToSendspinIdMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Queue manager service (created on demand)
|
||||||
|
QueueManagerService get _queueManager => QueueManagerService(
|
||||||
|
api: _api,
|
||||||
|
database: DatabaseService.instance,
|
||||||
|
logger: _logger,
|
||||||
|
getAvailablePlayers: () => _availablePlayers,
|
||||||
|
getCastToSendspinMap: () => _castToSendspinIdMap,
|
||||||
|
playTracks: playTracks,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search service (created on demand)
|
||||||
|
SearchService get _searchService => SearchService(
|
||||||
|
api: _api,
|
||||||
|
cacheService: _cacheService,
|
||||||
|
logger: _logger,
|
||||||
|
);
|
||||||
|
|
||||||
// Search state persistence
|
// Search state persistence
|
||||||
String _lastSearchQuery = '';
|
String _lastSearchQuery = '';
|
||||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||||
@@ -119,10 +173,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
'tracks': [],
|
'tracks': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 = {};
|
|
||||||
|
|
||||||
// Provider filter: list of allowed provider instance IDs from MA user settings
|
// Provider filter: list of allowed provider instance IDs from MA user settings
|
||||||
// Empty list means all providers are allowed
|
// Empty list means all providers are allowed
|
||||||
List<String> _providerFilter = [];
|
List<String> _providerFilter = [];
|
||||||
@@ -142,13 +192,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// GETTERS
|
// GETTERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
MAConnectionState get connectionState => _connectionState;
|
// Connection getters - use facade's own state (sub-providers not yet wired)
|
||||||
String? get serverUrl => _serverUrl;
|
MAConnectionState get connectionState => _connectionState ?? MAConnectionState.disconnected;
|
||||||
String? get error => _error;
|
|
||||||
bool get isConnected => _connectionState == MAConnectionState.connected ||
|
|
||||||
_connectionState == MAConnectionState.authenticated;
|
|
||||||
|
|
||||||
// Library getters with provider filtering applied
|
String? get serverUrl => _serverUrl;
|
||||||
|
|
||||||
|
String? get error => _error;
|
||||||
|
|
||||||
|
bool get isConnected => _connectionState == MAConnectionState.connected;
|
||||||
|
|
||||||
|
MusicAssistantAPI? get api => _api;
|
||||||
|
|
||||||
|
List<String> get providerFilter => _providerFilter;
|
||||||
|
|
||||||
|
List<String> get playerFilter => _playerFilter;
|
||||||
|
|
||||||
|
// Library getters - delegate to LibraryProvider when available
|
||||||
|
// TEMP: Disable delegation to debug context.select error
|
||||||
List<Artist> get artists => filterByProvider(_artists);
|
List<Artist> get artists => filterByProvider(_artists);
|
||||||
List<Album> get albums => filterByProvider(_albums);
|
List<Album> get albums => filterByProvider(_albums);
|
||||||
List<Track> get tracks => filterByProvider(_tracks);
|
List<Track> get tracks => filterByProvider(_tracks);
|
||||||
@@ -160,15 +220,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
List<Album> get albumsUnfiltered => _albums;
|
List<Album> get albumsUnfiltered => _albums;
|
||||||
List<MediaItem> get podcastsUnfiltered => _podcasts;
|
List<MediaItem> get podcastsUnfiltered => _podcasts;
|
||||||
List<MediaItem> get radioStationsUnfiltered => _radioStations;
|
List<MediaItem> get radioStationsUnfiltered => _radioStations;
|
||||||
|
|
||||||
|
// Library loading states - for now keep local state as LibraryProvider doesn't expose these
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
bool get isLoadingRadio => _isLoadingRadio;
|
bool get isLoadingRadio => _isLoadingRadio;
|
||||||
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
||||||
|
|
||||||
/// Provider filter from MA user settings (empty = all providers allowed)
|
|
||||||
List<String> get providerFilter => _providerFilter;
|
|
||||||
|
|
||||||
/// Whether provider filtering is active
|
/// Whether provider filtering is active
|
||||||
bool get hasProviderFilter => _providerFilter.isNotEmpty;
|
bool get hasProviderFilter => _providerFilterService.hasProviderFilter;
|
||||||
|
|
||||||
/// Check if a media item should be visible based on provider filter
|
/// Check if a media item should be visible based on provider filter
|
||||||
/// Returns true if:
|
/// Returns true if:
|
||||||
@@ -176,58 +235,30 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// - The item has at least one provider mapping in the allowed list
|
/// - The item has at least one provider mapping in the allowed list
|
||||||
/// - The item's primary provider is in the allowed list
|
/// - The item's primary provider is in the allowed list
|
||||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||||
// No filter = show everything
|
return _providerFilterService.isItemAllowedByProviderFilter(item);
|
||||||
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
|
/// Filter a list of media items based on provider filter
|
||||||
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
||||||
if (_providerFilter.isEmpty) return items;
|
return _providerFilterService.filterByProvider(items);
|
||||||
return items.where(isItemAllowedByProviderFilter).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter search results map based on provider filter
|
/// Filter search results map based on provider filter
|
||||||
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
||||||
if (_providerFilter.isEmpty) return results;
|
return _providerFilterService.filterSearchResults(results);
|
||||||
return {
|
|
||||||
for (final entry in results.entries)
|
|
||||||
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Player filter from MA user settings (empty = all players allowed)
|
|
||||||
List<String> get playerFilter => _playerFilter;
|
|
||||||
|
|
||||||
/// Whether player filtering is active
|
/// Whether player filtering is active
|
||||||
bool get hasPlayerFilter => _playerFilter.isNotEmpty;
|
bool get hasPlayerFilter => _providerFilterService.hasPlayerFilter;
|
||||||
|
|
||||||
/// Check if a player should be visible based on player filter
|
/// Check if a player should be visible based on player filter
|
||||||
bool isPlayerAllowedByFilter(Player player) {
|
bool isPlayerAllowedByFilter(Player player) {
|
||||||
if (_playerFilter.isEmpty) return true;
|
return _providerFilterService.isPlayerAllowedByFilter(player);
|
||||||
return _playerFilter.contains(player.playerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filter a list of players based on player filter
|
/// Filter a list of players based on player filter
|
||||||
List<Player> filterPlayers(List<Player> players) {
|
List<Player> filterPlayers(List<Player> players) {
|
||||||
if (_playerFilter.isEmpty) return players;
|
return _providerFilterService.filterPlayers(players);
|
||||||
return players.where(isPlayerAllowedByFilter).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -244,12 +275,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// Get SVG icon for a provider domain
|
/// Get SVG icon for a provider domain
|
||||||
/// Returns null if no icon is available
|
/// Returns null if no icon is available
|
||||||
String? getProviderIconSvg(String domain, {bool isDark = false}) {
|
String? getProviderIconSvg(String domain, {bool isDark = false}) {
|
||||||
return _api?.getProviderIconSvg(domain, isDark: isDark);
|
return _imageHelper?.getProviderIconSvg(domain, isDark: isDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get provider manifest by domain
|
/// Get provider manifest by domain
|
||||||
ProviderManifest? getProviderManifest(String domain) {
|
ProviderManifest? getProviderManifest(String domain) {
|
||||||
return _api?.getProviderManifest(domain);
|
return _imageHelper?.getProviderManifest(domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List of enabled provider instance IDs (empty = all enabled)
|
/// List of enabled provider instance IDs (empty = all enabled)
|
||||||
@@ -480,12 +511,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
SyncStatus get syncStatus => SyncService.instance.status;
|
SyncStatus get syncStatus => SyncService.instance.status;
|
||||||
|
|
||||||
/// Selected player - loads from cache if not yet set
|
/// Selected player - loads from cache if not yet set
|
||||||
Player? get selectedPlayer {
|
Player? get selectedPlayer => _selectedPlayer;
|
||||||
if (_selectedPlayer == null && _cacheService.getCachedSelectedPlayer() != null) {
|
|
||||||
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
|
|
||||||
}
|
|
||||||
return _selectedPlayer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Available players - loads from cache for instant UI display
|
/// Available players - loads from cache for instant UI display
|
||||||
/// Filtered by player_filter if active
|
/// Filtered by player_filter if active
|
||||||
@@ -500,70 +526,17 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// Raw unfiltered players list (for internal use)
|
/// Raw unfiltered players list (for internal use)
|
||||||
List<Player> get availablePlayersUnfiltered => _availablePlayers;
|
List<Player> get availablePlayersUnfiltered => _availablePlayers;
|
||||||
|
|
||||||
/// Check if a player should show the "manually synced" indicator (yellow border)
|
/// Check if a player should show the "manually synced" indicator (yellow border).
|
||||||
/// Returns true for BOTH the leader AND children of a manually created sync group
|
///
|
||||||
/// Excludes pre-configured MA speaker groups (provider = 'player_group')
|
/// Returns true for BOTH the leader AND children of a manually created sync group.
|
||||||
|
/// Excludes pre-configured MA speaker groups (provider = 'player_group').
|
||||||
bool isPlayerManuallySynced(String playerId) {
|
bool isPlayerManuallySynced(String playerId) {
|
||||||
final player = _availablePlayers.where((p) => p.playerId == playerId).firstOrNull;
|
return _playerSyncState.isPlayerManuallySynced(playerId);
|
||||||
if (player == null) return false;
|
|
||||||
|
|
||||||
// Group players (like "All Speakers") should NEVER have yellow border
|
|
||||||
// They are pre-configured containers, not manually synced players
|
|
||||||
// Check this FIRST before any other logic to prevent edge cases
|
|
||||||
if (player.provider == 'player_group') return false;
|
|
||||||
|
|
||||||
// Case 1: Player is a child synced to another player
|
|
||||||
if (player.syncedTo != null) {
|
|
||||||
// Look up sync target - also check translated IDs for Cast+Sendspin players
|
|
||||||
// The syncedTo might contain a Cast ID but the player list has the Sendspin version
|
|
||||||
Player? syncTarget = _availablePlayers.where((p) => p.playerId == player.syncedTo).firstOrNull;
|
|
||||||
|
|
||||||
// If not found, try looking up by translated Sendspin ID
|
|
||||||
if (syncTarget == null) {
|
|
||||||
final translatedId = _castToSendspinIdMap[player.syncedTo];
|
|
||||||
if (translatedId != null) {
|
|
||||||
syncTarget = _availablePlayers.where((p) => p.playerId == translatedId).firstOrNull;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check reverse: syncedTo might be Sendspin ID, look for Cast player
|
|
||||||
if (syncTarget == null) {
|
|
||||||
// Build reverse map on demand
|
|
||||||
for (final entry in _castToSendspinIdMap.entries) {
|
|
||||||
if (entry.value == player.syncedTo) {
|
|
||||||
syncTarget = _availablePlayers.where((p) => p.playerId == entry.key).firstOrNull;
|
|
||||||
if (syncTarget != null) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (syncTarget == null) return false;
|
|
||||||
|
|
||||||
// If synced to a group player, it's part of a pre-configured group
|
|
||||||
if (syncTarget.provider == 'player_group') return false;
|
|
||||||
|
|
||||||
// Synced to a regular player - this is a manual sync child
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Player is a leader with group members
|
|
||||||
if (player.groupMembers != null && player.groupMembers!.length > 1) {
|
|
||||||
// Key distinction: In a MANUAL sync, the leader's own ID is in groupMembers
|
|
||||||
// In a PRE-CONFIGURED group (UGP), the group player's ID is NOT in groupMembers
|
|
||||||
// (the members are the child players, not including the group itself)
|
|
||||||
final isInOwnGroup = player.groupMembers!.contains(player.playerId);
|
|
||||||
if (!isInOwnGroup) {
|
|
||||||
// This is a pre-configured group player (like "All Speakers")
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Leader's ID is in groupMembers = manual sync
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Track? get currentTrack => _currentTrack;
|
Track? get currentTrack => _currentTrack;
|
||||||
|
Audiobook? get currentAudiobook => _currentAudiobook;
|
||||||
|
Map<String, String> get castToSendspinIdMap => _castToSendspinIdMap;
|
||||||
|
|
||||||
// Sleep timer getters
|
// Sleep timer getters
|
||||||
bool get sleepTimerActive => _sleepTimerMinutes != null;
|
bool get sleepTimerActive => _sleepTimerMinutes != null;
|
||||||
@@ -580,11 +553,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// Home refresh counter - increments when home rows should refresh their data
|
/// Home refresh counter - increments when home rows should refresh their data
|
||||||
int get homeRefreshCounter => _homeRefreshCounter;
|
int get homeRefreshCounter => _homeRefreshCounter;
|
||||||
|
|
||||||
/// Currently playing audiobook context (with chapters) - set when playing an audiobook
|
|
||||||
Audiobook? get currentAudiobook => _currentAudiobook;
|
|
||||||
|
|
||||||
/// Whether we're currently playing an audiobook
|
/// Whether we're currently playing an audiobook
|
||||||
bool get isPlayingAudiobook => _currentAudiobook != null;
|
bool get isPlayingAudiobook => currentAudiobook != null;
|
||||||
|
|
||||||
/// Whether we're currently playing a podcast episode
|
/// Whether we're currently playing a podcast episode
|
||||||
/// Detected by checking if the current track's URI contains podcast_episode
|
/// Detected by checking if the current track's URI contains podcast_episode
|
||||||
@@ -660,7 +630,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
String get lastSearchQuery => _lastSearchQuery;
|
String get lastSearchQuery => _lastSearchQuery;
|
||||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||||
|
|
||||||
MusicAssistantAPI? get api => _api;
|
|
||||||
AuthManager get authManager => _authManager;
|
AuthManager get authManager => _authManager;
|
||||||
|
|
||||||
/// Position tracker for playback progress - single source of truth
|
/// Position tracker for playback progress - single source of truth
|
||||||
@@ -777,9 +746,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Get artwork URL for a player from cache
|
/// Get artwork URL for a player from cache
|
||||||
String? getCachedArtworkUrl(String playerId, {int size = 512}) {
|
String? getCachedArtworkUrl(String playerId, {int size = 512}) {
|
||||||
final track = getCachedTrackForPlayer(playerId);
|
return _imageHelper?.getCachedArtworkUrl(playerId, size: size);
|
||||||
if (track == null) return null;
|
|
||||||
return getImageUrl(track, size: size);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear cached track for a player (e.g., after queue transfer)
|
/// Clear cached track for a player (e.g., after queue transfer)
|
||||||
@@ -798,12 +765,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// INITIALIZATION
|
// INITIALIZATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
MusicAssistantProvider() {
|
MusicAssistantProvider({
|
||||||
|
ConnectionProvider? connectionProvider,
|
||||||
|
LibraryProvider? libraryProvider,
|
||||||
|
PlayerProvider? playerProvider,
|
||||||
|
}) : _connectionProvider = connectionProvider,
|
||||||
|
_libraryProvider = libraryProvider,
|
||||||
|
_playerProvider = playerProvider {
|
||||||
_localPlayer = LocalPlayerService(_authManager);
|
_localPlayer = LocalPlayerService(_authManager);
|
||||||
_initialize();
|
_initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initialize() async {
|
Future<void> _initialize() async {
|
||||||
|
// Set up ConnectionProvider callbacks before connecting
|
||||||
|
if (_connectionProvider != null) {
|
||||||
|
_connectionProvider!.onConnected = _onConnectionProviderConnected;
|
||||||
|
_connectionProvider!.onAuthenticated = _onConnectionProviderAuthenticated;
|
||||||
|
_connectionProvider!.onDisconnected = _onConnectionProviderDisconnected;
|
||||||
|
}
|
||||||
|
|
||||||
_serverUrl = await SettingsService.getServerUrl();
|
_serverUrl = await SettingsService.getServerUrl();
|
||||||
|
|
||||||
// Load cached players from database for instant display (before connecting)
|
// Load cached players from database for instant display (before connecting)
|
||||||
@@ -940,8 +920,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Future<void> _loadPodcastCoverCache() async {
|
Future<void> _loadPodcastCoverCache() async {
|
||||||
try {
|
try {
|
||||||
final cache = await SettingsService.getPodcastCoverCache();
|
final cache = await SettingsService.getPodcastCoverCache();
|
||||||
if (cache.isNotEmpty) {
|
if (cache.isNotEmpty && _imageHelper != null) {
|
||||||
_podcastCoverCache.addAll(cache);
|
await _imageHelper!.loadPodcastCoverCache(cache);
|
||||||
_logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)');
|
_logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1032,11 +1012,89 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONNECTION PROVIDER CALLBACKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Called by ConnectionProvider when WebSocket connects (before auth)
|
||||||
|
Future<void> _onConnectionProviderConnected() async {
|
||||||
|
_logger.log('🔗 ConnectionProvider: WebSocket connected');
|
||||||
|
// No-op for now - initialization happens after authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by ConnectionProvider when authentication succeeds
|
||||||
|
Future<void> _onConnectionProviderAuthenticated() async {
|
||||||
|
_logger.log('✅ ConnectionProvider: Authenticated');
|
||||||
|
await _initializeAfterConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by ConnectionProvider when disconnected
|
||||||
|
void _onConnectionProviderDisconnected() {
|
||||||
|
_logger.log('📡 ConnectionProvider: Disconnected - keeping cached data');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up event subscriptions that are specific to the facade
|
||||||
|
void _setupEventSubscriptions() {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
// Subscribe to player events
|
||||||
|
_localPlayerEventSubscription?.cancel();
|
||||||
|
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
|
||||||
|
_handleLocalPlayerEvent,
|
||||||
|
onError: (error) => _logger.log('Builtin player event stream error: $error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
|
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
|
||||||
|
_handlePlayerUpdatedEvent,
|
||||||
|
onError: (error) => _logger.log('Player updated event stream error: $error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
_playerAddedEventSubscription?.cancel();
|
||||||
|
_playerAddedEventSubscription = _api!.playerAddedEvents.listen(
|
||||||
|
_handlePlayerAddedEvent,
|
||||||
|
onError: (error) => _logger.log('Player added event stream error: $error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
_mediaItemAddedEventSubscription?.cancel();
|
||||||
|
_mediaItemAddedEventSubscription = _api!.mediaItemAddedEvents.listen(
|
||||||
|
_handleMediaItemAddedEvent,
|
||||||
|
onError: (error) => _logger.log('Media item added event stream error: $error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
_mediaItemDeletedEventSubscription?.cancel();
|
||||||
|
_mediaItemDeletedEventSubscription = _api!.mediaItemDeletedEvents.listen(
|
||||||
|
_handleMediaItemDeletedEvent,
|
||||||
|
onError: (error) => _logger.log('Media item deleted event stream error: $error'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CONNECTION
|
// CONNECTION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
Future<void> connectToServer(String serverUrl) async {
|
Future<void> connectToServer(String serverUrl) async {
|
||||||
|
if (_connectionProvider != null) {
|
||||||
|
// Delegate to ConnectionProvider
|
||||||
|
await _connectionProvider!.connect(serverUrl);
|
||||||
|
|
||||||
|
// After ConnectionProvider connects, get the API reference and initialize facade-specific stuff
|
||||||
|
_api = _connectionProvider!.api;
|
||||||
|
_imageHelper = ImageHelperService(
|
||||||
|
api: _api,
|
||||||
|
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up facade-specific event subscriptions
|
||||||
|
_setupEventSubscriptions();
|
||||||
|
} else {
|
||||||
|
// Fallback to old behavior when ConnectionProvider is not available
|
||||||
|
await _connectToServerLegacy(serverUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy connection method (used when ConnectionProvider is not provided)
|
||||||
|
Future<void> _connectToServerLegacy(String serverUrl) async {
|
||||||
try {
|
try {
|
||||||
_error = null;
|
_error = null;
|
||||||
_serverUrl = serverUrl;
|
_serverUrl = serverUrl;
|
||||||
@@ -1047,6 +1105,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||||
|
|
||||||
|
// Initialize image helper service with API reference
|
||||||
|
_imageHelper = ImageHelperService(
|
||||||
|
api: _api,
|
||||||
|
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
|
||||||
|
);
|
||||||
|
|
||||||
_connectionStateSubscription?.cancel();
|
_connectionStateSubscription?.cancel();
|
||||||
_connectionStateSubscription = _api!.connectionState.listen(
|
_connectionStateSubscription = _api!.connectionState.listen(
|
||||||
(state) async {
|
(state) async {
|
||||||
@@ -1089,36 +1153,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
_localPlayerEventSubscription?.cancel();
|
// Set up event subscriptions using shared method
|
||||||
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
|
_setupEventSubscriptions();
|
||||||
_handleLocalPlayerEvent,
|
|
||||||
onError: (error) => _logger.log('Builtin player event stream error: $error'),
|
|
||||||
);
|
|
||||||
|
|
||||||
_playerUpdatedEventSubscription?.cancel();
|
|
||||||
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
|
|
||||||
_handlePlayerUpdatedEvent,
|
|
||||||
onError: (error) => _logger.log('Player updated event stream error: $error'),
|
|
||||||
);
|
|
||||||
|
|
||||||
_playerAddedEventSubscription?.cancel();
|
|
||||||
_playerAddedEventSubscription = _api!.playerAddedEvents.listen(
|
|
||||||
_handlePlayerAddedEvent,
|
|
||||||
onError: (error) => _logger.log('Player added event stream error: $error'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe to library change events for instant UI updates
|
|
||||||
_mediaItemAddedEventSubscription?.cancel();
|
|
||||||
_mediaItemAddedEventSubscription = _api!.mediaItemAddedEvents.listen(
|
|
||||||
_handleMediaItemAddedEvent,
|
|
||||||
onError: (error) => _logger.log('Media item added event stream error: $error'),
|
|
||||||
);
|
|
||||||
|
|
||||||
_mediaItemDeletedEventSubscription?.cancel();
|
|
||||||
_mediaItemDeletedEventSubscription = _api!.mediaItemDeletedEvents.listen(
|
|
||||||
_handleMediaItemDeletedEvent,
|
|
||||||
onError: (error) => _logger.log('Media item deleted event stream error: $error'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _api!.connect();
|
await _api!.connect();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -2394,7 +2430,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
void _handleSendspinVolume(int volumeLevel) async {
|
void _handleSendspinVolume(int volumeLevel) async {
|
||||||
_logger.log('🔊 Sendspin: Set volume to $volumeLevel');
|
_logger.log('🔊 Sendspin: Set volume to $volumeLevel');
|
||||||
_localPlayerVolume = volumeLevel;
|
_localPlayerVolume = volumeLevel;
|
||||||
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
|
await FlutterVolumeController.setVolume(volumeLevel / VolumeConstants.range.toDouble());
|
||||||
_sendspinService?.reportState(volume: volumeLevel);
|
_sendspinService?.reportState(volume: volumeLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2652,7 +2688,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final volume = event['volume_level'] as int? ?? event['volume'] as int?;
|
final volume = event['volume_level'] as int? ?? event['volume'] as int?;
|
||||||
if (volume != null) {
|
if (volume != null) {
|
||||||
_localPlayerVolume = volume;
|
_localPlayerVolume = volume;
|
||||||
await FlutterVolumeController.setVolume(volume / 100.0);
|
await FlutterVolumeController.setVolume(volume / VolumeConstants.range.toDouble());
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -3667,44 +3703,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false, bool libraryOnly = false}) async {
|
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false, bool libraryOnly = false}) async {
|
||||||
final baseKey = query.toLowerCase().trim();
|
return _searchService.searchWithCache(query, forceRefresh: forceRefresh, libraryOnly: libraryOnly);
|
||||||
if (baseKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
|
||||||
|
|
||||||
// Include libraryOnly in cache key to separate results
|
|
||||||
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
|
|
||||||
|
|
||||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
|
||||||
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
|
|
||||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_api == null) {
|
|
||||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_logger.log('🔄 Searching for "$query" (libraryOnly: $libraryOnly)...');
|
|
||||||
final results = await _api!.search(query, libraryOnly: libraryOnly);
|
|
||||||
|
|
||||||
final cachedResults = <String, List<MediaItem>>{
|
|
||||||
'artists': results['artists'] ?? [],
|
|
||||||
'albums': results['albums'] ?? [],
|
|
||||||
'tracks': results['tracks'] ?? [],
|
|
||||||
'playlists': results['playlists'] ?? [],
|
|
||||||
'audiobooks': results['audiobooks'] ?? [],
|
|
||||||
};
|
|
||||||
|
|
||||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
|
||||||
_logger.log('✅ Cached search results for "$query"');
|
|
||||||
return cachedResults;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('❌ Search failed: $e');
|
|
||||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAllDetailCaches() {
|
void clearAllDetailCaches() {
|
||||||
_cacheService.clearAllDetailCaches();
|
_searchService.clearAllDetailCaches();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get cached album tracks (for instant display before background refresh)
|
/// Get cached album tracks (for instant display before background refresh)
|
||||||
@@ -3737,32 +3740,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// Sort players list based on smart sort setting
|
/// Sort players list based on smart sort setting
|
||||||
/// Can be called synchronously with pre-fetched settings for cached players
|
/// Can be called synchronously with pre-fetched settings for cached players
|
||||||
void _sortPlayersSync(List<Player> players, bool smartSort, String? builtinPlayerId) {
|
void _sortPlayersSync(List<Player> players, bool smartSort, String? builtinPlayerId) {
|
||||||
if (smartSort) {
|
PlayerSortUtility.sortPlayers(
|
||||||
// Smart sort: local player first, then playing, then on, then off
|
players,
|
||||||
players.sort((a, b) {
|
smartSort: smartSort,
|
||||||
// Local player always first
|
builtinPlayerId: builtinPlayerId,
|
||||||
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
);
|
||||||
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
|
||||||
if (aIsLocal && !bIsLocal) return -1;
|
|
||||||
if (bIsLocal && !aIsLocal) return 1;
|
|
||||||
|
|
||||||
// Then by status: playing > on > off
|
|
||||||
int statusPriority(Player p) {
|
|
||||||
if (p.state == 'playing') return 0;
|
|
||||||
if (p.powered && p.state != 'off') return 1;
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
final aPriority = statusPriority(a);
|
|
||||||
final bPriority = statusPriority(b);
|
|
||||||
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
|
|
||||||
|
|
||||||
// Within same status, sort alphabetically
|
|
||||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Default alphabetical sort
|
|
||||||
players.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
||||||
@@ -4126,7 +4108,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Update notification for builtin player using local mode method (keeps pause working)
|
// Update notification for builtin player using local mode method (keeps pause working)
|
||||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||||
final track = _currentTrack!;
|
final track = _currentTrack!;
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
|
||||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||||
? '${track.artistsString} • ${player.name}'
|
? '${track.artistsString} • ${player.name}'
|
||||||
: player.name;
|
: player.name;
|
||||||
@@ -4165,7 +4147,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// and the player is playing (don't wait for polling to kick in)
|
// and the player is playing (don't wait for polling to kick in)
|
||||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||||
final track = _currentTrack!;
|
final track = _currentTrack!;
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
|
||||||
// Include player name in artist line: "Artist • Player Name"
|
// Include player name in artist line: "Artist • Player Name"
|
||||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||||
? '${track.artistsString} • ${player.name}'
|
? '${track.artistsString} • ${player.name}'
|
||||||
@@ -4348,7 +4330,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
|
||||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||||
? '${track.artistsString} • ${_selectedPlayer!.name}'
|
? '${track.artistsString} • ${_selectedPlayer!.name}'
|
||||||
: _selectedPlayer!.name;
|
: _selectedPlayer!.name;
|
||||||
@@ -4454,7 +4436,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also precache the image so it's ready for swipe preview
|
// Also precache the image so it's ready for swipe preview
|
||||||
final imageUrl = getImageUrl(track, size: 512);
|
final imageUrl = getImageUrl(track, size: ImageSizes.highRes);
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
_precacheImage(imageUrl);
|
_precacheImage(imageUrl);
|
||||||
}
|
}
|
||||||
@@ -4803,7 +4785,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Update notification for ALL players
|
// Update notification for ALL players
|
||||||
final track = _currentTrack!;
|
final track = _currentTrack!;
|
||||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
|
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
|
||||||
|
|
||||||
@@ -4906,7 +4888,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
title: track.name,
|
title: track.name,
|
||||||
artist: track.artistsString,
|
artist: track.artistsString,
|
||||||
album: track.album?.name,
|
album: track.album?.name,
|
||||||
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null,
|
artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: ImageSizes.highRes) ?? '') : null,
|
||||||
duration: track.duration,
|
duration: track.duration,
|
||||||
);
|
);
|
||||||
audioHandler.updateLocalModeNotification(
|
audioHandler.updateLocalModeNotification(
|
||||||
@@ -4995,6 +4977,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
Future<void> loadLibrary() async {
|
Future<void> loadLibrary() async {
|
||||||
|
if (_libraryProvider != null) {
|
||||||
|
// Delegate to LibraryProvider
|
||||||
|
await _libraryProvider!.loadLibrary();
|
||||||
|
notifyListeners();
|
||||||
|
} else {
|
||||||
|
// Fallback to legacy behavior
|
||||||
|
await _loadLibraryLegacy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy library loading method (used when LibraryProvider is not provided)
|
||||||
|
Future<void> _loadLibraryLegacy() async {
|
||||||
if (!isConnected) return;
|
if (!isConnected) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -5105,18 +5099,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
/// Load high-resolution podcast covers from iTunes in background
|
/// Load high-resolution podcast covers from iTunes in background
|
||||||
/// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency)
|
/// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency)
|
||||||
Future<void> _loadPodcastCoversInBackground() async {
|
Future<void> _loadPodcastCoversInBackground() async {
|
||||||
if (_api == null) return;
|
if (_api == null || _imageHelper == null) return;
|
||||||
|
|
||||||
for (final podcast in _podcasts) {
|
for (final podcast in _podcasts) {
|
||||||
try {
|
try {
|
||||||
// Skip if already cached (either in memory or loaded from persistence)
|
// Skip if already cached (either in memory or loaded from persistence)
|
||||||
if (_podcastCoverCache.containsKey(podcast.itemId)) continue;
|
if (_imageHelper!.podcastCoverCache.containsKey(podcast.itemId)) continue;
|
||||||
|
|
||||||
// Try iTunes for high-res artwork
|
// Try iTunes for high-res artwork
|
||||||
final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name);
|
final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name);
|
||||||
|
|
||||||
if (itunesArtwork != null) {
|
if (itunesArtwork != null) {
|
||||||
_podcastCoverCache[podcast.itemId] = itunesArtwork;
|
_imageHelper!.updatePodcastCoverCache(podcast.itemId, itunesArtwork);
|
||||||
_logger.log('🎙️ Cached iTunes artwork for ${podcast.name}');
|
_logger.log('🎙️ Cached iTunes artwork for ${podcast.name}');
|
||||||
|
|
||||||
// Persist to storage for instant display on next launch
|
// Persist to storage for instant display on next launch
|
||||||
@@ -5250,33 +5244,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? getImageUrl(MediaItem item, {int size = 256}) {
|
String? getImageUrl(MediaItem item, {int size = 256}) {
|
||||||
return _api?.getImageUrl(item, size: size);
|
return _imageHelper?.getImageUrl(item, size: size);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get best available podcast cover URL
|
/// Get best available podcast cover URL
|
||||||
/// Returns cached iTunes URL (800x800) if available, otherwise falls back to MA imageproxy
|
/// 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
|
/// The cache is persisted to storage and loaded on app start for instant high-res display
|
||||||
String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) {
|
String? getPodcastImageUrl(MediaItem podcast, {int size = 256}) {
|
||||||
// Return cached iTunes URL if available (persisted across app launches)
|
return _imageHelper?.getPodcastImageUrl(podcast, size: size);
|
||||||
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)
|
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
|
||||||
/// Returns a Future since fallback requires async API calls
|
/// Returns a Future since fallback requires async API calls
|
||||||
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
|
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
|
||||||
// Try Music Assistant first
|
return _imageHelper?.getArtistImageUrlWithFallback(artist, size: size);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -5288,104 +5269,17 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<PlayerQueue?> getQueue(String playerId) async {
|
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||||
// If this player is a group child, fetch the leader's queue instead
|
return _queueManager.getQueue(playerId);
|
||||||
// This ensures grouped players show the same queue as their leader
|
|
||||||
String effectivePlayerId = playerId;
|
|
||||||
final player = _availablePlayers.firstWhere(
|
|
||||||
(p) => p.playerId == playerId,
|
|
||||||
orElse: () => Player(
|
|
||||||
playerId: playerId,
|
|
||||||
name: '',
|
|
||||||
available: false,
|
|
||||||
powered: false,
|
|
||||||
state: 'idle',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
|
||||||
if (effectivePlayerId.startsWith('cast-') && effectivePlayerId.length >= 13) {
|
|
||||||
// Sendspin ID format: cast-7df484e3 -> need Cast UUID starting with 7df484e3
|
|
||||||
final prefix = effectivePlayerId.substring(5); // Remove "cast-"
|
|
||||||
// Reverse lookup in the map
|
|
||||||
for (final entry in _castToSendspinIdMap.entries) {
|
|
||||||
if (entry.value == effectivePlayerId) {
|
|
||||||
_logger.log('🔗 Translated Sendspin ID $effectivePlayerId to Cast UUID ${entry.key} for queue fetch');
|
|
||||||
effectivePlayerId = entry.key;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If not in map, try to find in available players
|
|
||||||
if (effectivePlayerId.startsWith('cast-')) {
|
|
||||||
for (final p in _availablePlayers) {
|
|
||||||
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
|
|
||||||
_logger.log('🔗 Found Cast UUID ${p.playerId} for Sendspin ID $effectivePlayerId via player lookup');
|
|
||||||
effectivePlayerId = p.playerId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
/// Get cached queue for instant display (before API refresh)
|
||||||
Future<PlayerQueue?> getCachedQueue(String playerId) async {
|
Future<PlayerQueue?> getCachedQueue(String playerId) async {
|
||||||
try {
|
return _queueManager.getCachedQueue(playerId);
|
||||||
if (!DatabaseService.instance.isInitialized) return null;
|
|
||||||
|
|
||||||
final cachedItems = await DatabaseService.instance.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
|
/// Persist queue to database for app restart persistence
|
||||||
void _persistQueueToDatabase(String playerId, PlayerQueue queue) {
|
void _persistQueueToDatabase(String playerId, PlayerQueue queue) {
|
||||||
() async {
|
_queueManager.persistQueueToDatabase(playerId, queue);
|
||||||
try {
|
|
||||||
if (!DatabaseService.instance.isInitialized) return;
|
|
||||||
|
|
||||||
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
|
|
||||||
await DatabaseService.instance.saveQueue(playerId, itemJsonList);
|
|
||||||
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('⚠️ Error persisting queue to database: $e');
|
|
||||||
}
|
|
||||||
}();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||||
@@ -5535,42 +5429,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Attempt to restore the queue from cached data and start playback
|
/// Attempt to restore the queue from cached data and start playback
|
||||||
Future<bool> _restoreQueueFromCache(String playerId) async {
|
Future<bool> _restoreQueueFromCache(String playerId) async {
|
||||||
try {
|
return _queueManager.restoreQueueFromCache(playerId);
|
||||||
final cachedQueue = await getCachedQueue(playerId);
|
|
||||||
if (cachedQueue == null || cachedQueue.items.isEmpty) {
|
|
||||||
_logger.log('⚠️ No cached queue to restore');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract tracks from queue items
|
|
||||||
// playTracks needs either: providerMappings with available entries, OR provider+itemId
|
|
||||||
final tracks = cachedQueue.items
|
|
||||||
.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();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> nextTrack(String playerId) async {
|
Future<void> nextTrack(String playerId) async {
|
||||||
@@ -5955,7 +5814,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
if (builtinPlayerId != null && playerId == builtinPlayerId) {
|
if (builtinPlayerId != null && playerId == builtinPlayerId) {
|
||||||
_localPlayerVolume = volumeLevel;
|
_localPlayerVolume = volumeLevel;
|
||||||
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
|
await FlutterVolumeController.setVolume(volumeLevel / VolumeConstants.range.toDouble());
|
||||||
}
|
}
|
||||||
await _api?.setVolume(playerId, volumeLevel);
|
await _api?.setVolume(playerId, volumeLevel);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -6118,21 +5977,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
|
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
|
||||||
String nextMode;
|
final nextMode = QueueControls.getNextRepeatMode(currentMode);
|
||||||
switch (currentMode) {
|
|
||||||
case 'off':
|
|
||||||
case null:
|
|
||||||
nextMode = 'all';
|
|
||||||
break;
|
|
||||||
case 'all':
|
|
||||||
nextMode = 'one';
|
|
||||||
break;
|
|
||||||
case 'one':
|
|
||||||
nextMode = 'off';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
nextMode = 'off';
|
|
||||||
}
|
|
||||||
await setRepeatMode(queueId, nextMode);
|
await setRepeatMode(queueId, nextMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6162,19 +6007,5 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Check if a hostname is a local/private network address
|
/// Check if a hostname is a local/private network address
|
||||||
bool _isLocalNetworkHost(String host) {
|
bool _isLocalNetworkHost(String host) => NetworkUtils.isLocalNetworkHost(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'); // Tailscale - treat as local since it's a VPN
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
DateTime? _sleepTimerEndTime;
|
||||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||||
Timer? _sleepTimerDisplayTimer;
|
Timer? _sleepTimerDisplayTimer;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
/// Callback to pause playback when timer expires
|
/// Callback to pause playback when timer expires
|
||||||
OnSleepTimerExpired? onExpired;
|
OnSleepTimerExpired? onExpired;
|
||||||
@@ -74,7 +75,9 @@ class SleepTimerProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Start display update timer (every second for countdown)
|
// Start display update timer (every second for countdown)
|
||||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
notifyListeners(); // Update UI with remaining time
|
if (!_disposed) {
|
||||||
|
notifyListeners(); // Update UI with remaining time
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -123,6 +126,7 @@ class SleepTimerProvider with ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
_cancelInternal();
|
_cancelInternal();
|
||||||
super.dispose();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,10 +67,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
final service = LibraryStatusService.instance;
|
final service = LibraryStatusService.instance;
|
||||||
final key = libraryItemKey;
|
final key = libraryItemKey;
|
||||||
if (!service.isInLibrary(key) && widget.album.inLibrary) {
|
if (!service.isInLibrary(key) && widget.album.inLibrary) {
|
||||||
service.setLibraryStatus(key, true);
|
service.initLibraryStatus(key, true);
|
||||||
}
|
}
|
||||||
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
||||||
service.setFavoriteStatus(key, true);
|
service.initFavoriteStatus(key, true);
|
||||||
}
|
}
|
||||||
_loadTracks();
|
_loadTracks();
|
||||||
_loadAlbumDescription();
|
_loadAlbumDescription();
|
||||||
@@ -1298,12 +1298,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
|
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
|
||||||
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
|
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
// Add to queue on selected player
|
// Add to queue on selected player
|
||||||
final player = maProvider.selectedPlayer;
|
final player = maProvider.selectedPlayer;
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
|
try {
|
||||||
|
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -1312,7 +1313,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_showError(S.of(context)!.noPlayerSelected);
|
_showError(S.of(context)!.noPlayerSelected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
|
|||||||
final service = LibraryStatusService.instance;
|
final service = LibraryStatusService.instance;
|
||||||
final key = libraryItemKey;
|
final key = libraryItemKey;
|
||||||
if (!service.isInLibrary(key) && _checkIfInLibrary(widget.artist)) {
|
if (!service.isInLibrary(key) && _checkIfInLibrary(widget.artist)) {
|
||||||
service.setLibraryStatus(key, true);
|
service.initLibraryStatus(key, true);
|
||||||
}
|
}
|
||||||
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
||||||
service.setFavoriteStatus(key, true);
|
service.initFavoriteStatus(key, true);
|
||||||
}
|
}
|
||||||
// Use initial image URL immediately for smooth hero animation
|
// Use initial image URL immediately for smooth hero animation
|
||||||
_artistImageUrl = widget.initialImageUrl;
|
_artistImageUrl = widget.initialImageUrl;
|
||||||
|
|||||||
@@ -364,7 +364,6 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly),
|
padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly),
|
||||||
child: FloatingActionButton(
|
child: FloatingActionButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {});
|
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
_scrollController.animateTo(
|
_scrollController.animateTo(
|
||||||
_scrollController.position.maxScrollExtent,
|
_scrollController.position.maxScrollExtent,
|
||||||
|
|||||||
@@ -6,12 +6,52 @@ import '../widgets/album_card.dart';
|
|||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class LibraryAlbumsScreen extends StatelessWidget {
|
class LibraryAlbumsScreen extends StatefulWidget {
|
||||||
const LibraryAlbumsScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Use Selector for targeted rebuilds - only rebuild when albums or loading state changes
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
@@ -34,13 +74,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: Selector<MusicAssistantProvider, (List<Album>, bool)>(
|
body: _buildAlbumsList(context, _albums, _isLoading),
|
||||||
selector: (_, provider) => (provider.albums, provider.isLoading),
|
|
||||||
builder: (context, data, _) {
|
|
||||||
final (albums, isLoading) = data;
|
|
||||||
return _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 'artist_details_screen.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class LibraryArtistsScreen extends StatelessWidget {
|
class LibraryArtistsScreen extends StatefulWidget {
|
||||||
const LibraryArtistsScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
@@ -36,13 +76,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
body: _buildArtistsList(context, _artists, _isLoading),
|
||||||
selector: (_, provider) => (provider.artists, provider.isLoading),
|
|
||||||
builder: (context, data, _) {
|
|
||||||
final (artists, isLoading) = data;
|
|
||||||
return _buildArtistsList(context, artists, isLoading);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+527
-504
File diff suppressed because it is too large
Load Diff
@@ -590,12 +590,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
|
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
|
||||||
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
|
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
// Add to queue on selected player
|
// Add to queue on selected player
|
||||||
final player = maProvider.selectedPlayer;
|
final player = maProvider.selectedPlayer;
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
|
try {
|
||||||
|
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -604,7 +605,11 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_showError(S.of(context)!.noPlayerSelected);
|
_showError(S.of(context)!.noPlayerSelected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -598,35 +598,47 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Library-only toggle with circle background
|
// Library-only toggle with circle background
|
||||||
Tooltip(
|
LayoutBuilder(
|
||||||
message: S.of(context)!.libraryOnly,
|
builder: (context, constraints) {
|
||||||
child: SizedBox(
|
final control = SizedBox(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
|
color: colorScheme.surfaceContainerHighest.withOpacity(0.6),
|
||||||
shape: const CircleBorder(),
|
shape: const CircleBorder(),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_libraryOnly = !_libraryOnly;
|
_libraryOnly = !_libraryOnly;
|
||||||
});
|
});
|
||||||
if (_searchController.text.isNotEmpty) {
|
if (_searchController.text.isNotEmpty) {
|
||||||
_performSearch(_searchController.text);
|
_performSearch(_searchController.text);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Symbols.book_2,
|
Symbols.book_2,
|
||||||
fill: _libraryOnly ? 1 : 0,
|
fill: _libraryOnly ? 1 : 0,
|
||||||
size: 22,
|
size: 22,
|
||||||
color: _libraryOnly
|
color: _libraryOnly
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
: colorScheme.onSurface,
|
: colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
|
||||||
|
// Only use Tooltip if an Overlay ancestor exists
|
||||||
|
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||||
|
if (!hasOverlay) {
|
||||||
|
return control;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: S.of(context)!.libraryOnly,
|
||||||
|
child: control,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -56,18 +56,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Map<String, bool> _discoveryRowEnabled = {};
|
Map<String, bool> _discoveryRowEnabled = {};
|
||||||
bool _isLoadingDiscoveryFolders = false;
|
bool _isLoadingDiscoveryFolders = false;
|
||||||
|
|
||||||
/// Filtered row order for the draggable list (excludes discovery-mixes)
|
/// All available static home screen rows (that can be toggled on/off)
|
||||||
/// Shows rows in home order, plus any discovery rows from provider that aren't in home order yet
|
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 {
|
List<String> get _filteredRowOrder {
|
||||||
final rows = <String>[];
|
final rows = <String>[];
|
||||||
|
|
||||||
// First, add all rows from home order (excluding discovery-mixes)
|
// First, add all static rows in their current home order
|
||||||
for (final rowId in _homeRowOrder) {
|
for (final rowId in _homeRowOrder) {
|
||||||
if (rowId == 'discovery-mixes') continue;
|
if (rowId == 'discovery-mixes') continue;
|
||||||
rows.add(rowId);
|
rows.add(rowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then, add any discovery rows from provider that aren't in home order yet
|
// 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) {
|
for (final folder in _discoveryFolders) {
|
||||||
final discoveryRowId = 'discovery:${folder.itemId}';
|
final discoveryRowId = 'discovery:${folder.itemId}';
|
||||||
if (!_homeRowOrder.contains(discoveryRowId)) {
|
if (!_homeRowOrder.contains(discoveryRowId)) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,18 @@ class LibraryStatusService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
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)
|
/// Mark operation as pending (for rollback tracking)
|
||||||
void markLibraryPending(String key, bool previousValue) {
|
void markLibraryPending(String key, bool previousValue) {
|
||||||
_pendingLibraryOps[key] = previousValue;
|
_pendingLibraryOps[key] = previousValue;
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../models/player.dart';
|
||||||
|
import '../models/provider_instance.dart';
|
||||||
|
import '../services/sync_service.dart';
|
||||||
|
|
||||||
|
/// Service for filtering media items and players based on user settings.
|
||||||
|
///
|
||||||
|
/// Extracted from MusicAssistantProvider to reduce complexity.
|
||||||
|
class ProviderFilterService {
|
||||||
|
/// Provider filter from MA user settings (empty = all providers allowed)
|
||||||
|
final List<String> providerFilter;
|
||||||
|
|
||||||
|
/// Player filter from MA user settings (empty = all players allowed)
|
||||||
|
final List<String> playerFilter;
|
||||||
|
|
||||||
|
/// Available music providers discovered from MA
|
||||||
|
final List<ProviderInstance> availableMusicProviders;
|
||||||
|
|
||||||
|
/// Library data for provider counting
|
||||||
|
final List<dynamic> artists;
|
||||||
|
final List<dynamic> albums;
|
||||||
|
final List<dynamic> tracks;
|
||||||
|
final List<dynamic> podcasts;
|
||||||
|
final List<dynamic> radioStations;
|
||||||
|
|
||||||
|
ProviderFilterService({
|
||||||
|
required this.providerFilter,
|
||||||
|
required this.playerFilter,
|
||||||
|
required this.availableMusicProviders,
|
||||||
|
required this.artists,
|
||||||
|
required this.albums,
|
||||||
|
required this.tracks,
|
||||||
|
required this.podcasts,
|
||||||
|
required this.radioStations,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// PROVIDER FILTER (from MA user settings)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// Whether provider filtering is active
|
||||||
|
bool get hasProviderFilter => providerFilter.isNotEmpty;
|
||||||
|
|
||||||
|
/// Check if a media item should be visible based on provider filter.
|
||||||
|
///
|
||||||
|
/// Returns true if:
|
||||||
|
/// - No filter is active (empty list = all providers allowed)
|
||||||
|
/// - The item has at least one provider mapping in the allowed list
|
||||||
|
/// - The item's primary provider is in the allowed list
|
||||||
|
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||||
|
// No filter = show everything
|
||||||
|
if (providerFilter.isEmpty) return true;
|
||||||
|
|
||||||
|
// Check if item's provider mappings include any allowed provider
|
||||||
|
final mappings = item.providerMappings;
|
||||||
|
if (mappings != null && mappings.isNotEmpty) {
|
||||||
|
for (final mapping in mappings) {
|
||||||
|
if (providerFilter.contains(mapping.providerInstance)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check primary provider field (for items without full mappings)
|
||||||
|
if (providerFilter.contains(item.provider)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter a list of media items based on provider filter.
|
||||||
|
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
||||||
|
if (providerFilter.isEmpty) return items;
|
||||||
|
return items.where(isItemAllowedByProviderFilter).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter search results map based on provider filter.
|
||||||
|
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
||||||
|
if (providerFilter.isEmpty) return results;
|
||||||
|
return {
|
||||||
|
for (final entry in results.entries)
|
||||||
|
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// PLAYER FILTER (from MA user settings)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// Whether player filtering is active
|
||||||
|
bool get hasPlayerFilter => playerFilter.isNotEmpty;
|
||||||
|
|
||||||
|
/// Check if a player should be visible based on player filter.
|
||||||
|
bool isPlayerAllowedByFilter(Player player) {
|
||||||
|
if (playerFilter.isEmpty) return true;
|
||||||
|
return playerFilter.contains(player.playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter a list of players based on player filter.
|
||||||
|
List<Player> filterPlayers(List<Player> players) {
|
||||||
|
if (playerFilter.isEmpty) return players;
|
||||||
|
return players.where(isPlayerAllowedByFilter).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// PROVIDER COUNTING
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// Get providers that have content in artists library (with item counts).
|
||||||
|
Map<String, int> getProvidersWithArtists() {
|
||||||
|
return _getProvidersWithCounts(artists);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get providers that have content in albums library (with item counts).
|
||||||
|
Map<String, int> getProvidersWithAlbums() {
|
||||||
|
return _getProvidersWithCounts(albums);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get providers that have content in tracks library (with item counts).
|
||||||
|
Map<String, int> getProvidersWithTracks() {
|
||||||
|
return _getProvidersWithCounts(tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get providers that have content in playlists (with item counts).
|
||||||
|
Map<String, int> getProvidersWithPlaylists() {
|
||||||
|
return _getProvidersWithCounts(SyncService.instance.cachedPlaylists);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get providers that have audiobooks (with item counts).
|
||||||
|
Map<String, int> getProvidersWithAudiobooks() {
|
||||||
|
return _getProvidersWithCounts(SyncService.instance.cachedAudiobooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get providers that have radio stations (with item counts).
|
||||||
|
Map<String, int> getProvidersWithRadio() {
|
||||||
|
return _getProvidersWithCounts(radioStations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get providers that have podcasts (with item counts).
|
||||||
|
///
|
||||||
|
/// Only counts mappings where inLibrary is true - this indicates which provider
|
||||||
|
/// the user actually added the podcast from (vs providers that can play it).
|
||||||
|
Map<String, int> getProvidersWithPodcasts() {
|
||||||
|
final counts = <String, int>{};
|
||||||
|
for (final item in podcasts) {
|
||||||
|
final mappings = (item as MediaItem).providerMappings;
|
||||||
|
if (mappings != null) {
|
||||||
|
for (final mapping in mappings) {
|
||||||
|
// Only count if this provider "owns" the item (user added it from this account)
|
||||||
|
if (mapping.inLibrary) {
|
||||||
|
final instanceId = mapping.providerInstance;
|
||||||
|
if (instanceId.isNotEmpty) {
|
||||||
|
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal helper to count items per provider from a list of media items.
|
||||||
|
///
|
||||||
|
/// Only counts mappings where inLibrary is true - this indicates which provider
|
||||||
|
/// the user actually added the item from (vs providers that can play it).
|
||||||
|
Map<String, int> _getProvidersWithCounts(List<dynamic> items) {
|
||||||
|
final counts = <String, int>{};
|
||||||
|
for (final item in items) {
|
||||||
|
if (item is! MediaItem) continue;
|
||||||
|
final mediaItem = item as MediaItem;
|
||||||
|
final mappings = mediaItem.providerMappings;
|
||||||
|
if (mappings != null) {
|
||||||
|
for (final mapping in mappings) {
|
||||||
|
// Only count if this provider "owns" the item (user added it from this account)
|
||||||
|
if (mapping.inLibrary) {
|
||||||
|
final instanceId = mapping.providerInstance;
|
||||||
|
if (instanceId.isNotEmpty) {
|
||||||
|
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get ProviderInstance objects for providers that have content in a category.
|
||||||
|
///
|
||||||
|
/// Returns list of (ProviderInstance, itemCount) tuples, sorted by name.
|
||||||
|
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
|
||||||
|
final Map<String, int> counts;
|
||||||
|
switch (category) {
|
||||||
|
case 'artists':
|
||||||
|
counts = getProvidersWithArtists();
|
||||||
|
break;
|
||||||
|
case 'albums':
|
||||||
|
counts = getProvidersWithAlbums();
|
||||||
|
break;
|
||||||
|
case 'tracks':
|
||||||
|
counts = getProvidersWithTracks();
|
||||||
|
break;
|
||||||
|
case 'playlists':
|
||||||
|
counts = getProvidersWithPlaylists();
|
||||||
|
break;
|
||||||
|
case 'audiobooks':
|
||||||
|
counts = getProvidersWithAudiobooks();
|
||||||
|
break;
|
||||||
|
case 'radio':
|
||||||
|
counts = getProvidersWithRadio();
|
||||||
|
break;
|
||||||
|
case 'podcasts':
|
||||||
|
counts = getProvidersWithPodcasts();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
counts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build list of providers that support this category, with their item counts
|
||||||
|
// Only include providers that have at least 1 item (hides providers not synced to library)
|
||||||
|
// Multiple instances of the same domain are allowed (e.g., two Spotify accounts)
|
||||||
|
// but we dedupe if names match exactly (same account re-added with new instance ID)
|
||||||
|
final result = <(ProviderInstance, int)>[];
|
||||||
|
final addedInstanceIds = <String>{};
|
||||||
|
final addedNames = <String, int>{}; // Track name -> count to dedupe same-name providers
|
||||||
|
|
||||||
|
for (final provider in availableMusicProviders) {
|
||||||
|
// Only include providers that support this content type AND have items
|
||||||
|
if (provider.supportsContentType(category)) {
|
||||||
|
final count = counts[provider.instanceId] ?? 0;
|
||||||
|
if (count > 0) {
|
||||||
|
final existingCount = addedNames[provider.name];
|
||||||
|
if (existingCount == null) {
|
||||||
|
// First provider with this name
|
||||||
|
result.add((provider, count));
|
||||||
|
addedInstanceIds.add(provider.instanceId);
|
||||||
|
addedNames[provider.name] = count;
|
||||||
|
} else if (count > existingCount) {
|
||||||
|
// Same name but more items - likely same account re-added, keep better one
|
||||||
|
result.removeWhere((r) => r.$1.name == provider.name);
|
||||||
|
result.add((provider, count));
|
||||||
|
addedInstanceIds.add(provider.instanceId);
|
||||||
|
addedNames[provider.name] = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also include providers that have items but aren't in availableMusicProviders
|
||||||
|
// (e.g., search-only providers like iTunes, or orphaned items from removed providers)
|
||||||
|
// For these synthetic providers, dedupe by domain since we don't have account-specific names
|
||||||
|
final addedRealDomains = result.map((r) => r.$1.domain).toSet();
|
||||||
|
final addedSyntheticDomains = <String>{};
|
||||||
|
|
||||||
|
for (final entry in counts.entries) {
|
||||||
|
if (!addedInstanceIds.contains(entry.key) && entry.value > 0) {
|
||||||
|
// Create a synthetic ProviderInstance from the instance ID
|
||||||
|
// Format is typically "domain--uniqueId" (e.g., "itunes--abc123")
|
||||||
|
final instanceId = entry.key;
|
||||||
|
final domain = instanceId.contains('--')
|
||||||
|
? instanceId.split('--').first
|
||||||
|
: instanceId;
|
||||||
|
|
||||||
|
// Skip if this domain is already represented by a real provider or synthetic
|
||||||
|
if (addedRealDomains.contains(domain)) continue;
|
||||||
|
if (addedSyntheticDomains.contains(domain)) continue;
|
||||||
|
|
||||||
|
// Only add if this domain supports the category
|
||||||
|
final capabilities = ProviderInstance.providerCapabilities[domain];
|
||||||
|
if (capabilities != null && capabilities.contains(category)) {
|
||||||
|
result.add((
|
||||||
|
ProviderInstance(
|
||||||
|
instanceId: instanceId,
|
||||||
|
domain: domain,
|
||||||
|
name: _formatProviderName(domain),
|
||||||
|
available: true,
|
||||||
|
),
|
||||||
|
entry.value,
|
||||||
|
));
|
||||||
|
addedSyntheticDomains.add(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by name
|
||||||
|
result.sort((a, b) => a.$1.name.compareTo(b.$1.name));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a provider domain into a readable display name.
|
||||||
|
String _formatProviderName(String domain) {
|
||||||
|
const names = {
|
||||||
|
'spotify': 'Spotify',
|
||||||
|
'tidal': 'Tidal',
|
||||||
|
'qobuz': 'Qobuz',
|
||||||
|
'deezer': 'Deezer',
|
||||||
|
'ytmusic': 'YouTube Music',
|
||||||
|
'soundcloud': 'SoundCloud',
|
||||||
|
'apple_music': 'Apple Music',
|
||||||
|
'amazon_music': 'Amazon Music',
|
||||||
|
'plex': 'Plex',
|
||||||
|
'jellyfin': 'Jellyfin',
|
||||||
|
'emby': 'Emby',
|
||||||
|
'subsonic': 'Subsonic',
|
||||||
|
'opensubsonic': 'OpenSubsonic',
|
||||||
|
'gmusic': 'Google Music',
|
||||||
|
'bandcamp': 'Bandcamp',
|
||||||
|
'youtube': 'YouTube',
|
||||||
|
'itunes': 'iTunes/Apple',
|
||||||
|
'spotifyfree': 'Spotify Free',
|
||||||
|
'tunein': 'TuneIn',
|
||||||
|
'radio_net': 'Radio.net',
|
||||||
|
'dirble': 'Dirble',
|
||||||
|
'freq': 'FREQ',
|
||||||
|
'radionomy': 'Radionomy',
|
||||||
|
};
|
||||||
|
return names[domain] ?? domain.split('_').map((s) => s[0].toUpperCase() + s.substring(1)).join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../models/player.dart';
|
||||||
|
import '../services/database_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/error_handler.dart';
|
||||||
|
import '../services/music_assistant_api.dart';
|
||||||
|
|
||||||
|
/// Service for managing player queue operations.
|
||||||
|
///
|
||||||
|
/// Extracted from MusicAssistantProvider to isolate queue logic including:
|
||||||
|
/// - Queue fetching from API with player ID translation
|
||||||
|
/// - Queue caching to database for instant display
|
||||||
|
/// - Queue restoration from cache on app resume
|
||||||
|
/// - Track validation for queue items
|
||||||
|
class QueueManagerService {
|
||||||
|
final MusicAssistantAPI? _api;
|
||||||
|
final DatabaseService _database;
|
||||||
|
final DebugLogger _logger;
|
||||||
|
final List<Player> Function() getAvailablePlayers;
|
||||||
|
final Map<String, String> Function() getCastToSendspinMap;
|
||||||
|
final Future<void> Function(String playerId, List<Track> tracks, {int startIndex}) playTracks;
|
||||||
|
|
||||||
|
QueueManagerService({
|
||||||
|
required MusicAssistantAPI? api,
|
||||||
|
required DatabaseService database,
|
||||||
|
required DebugLogger logger,
|
||||||
|
required this.getAvailablePlayers,
|
||||||
|
required this.getCastToSendspinMap,
|
||||||
|
required this.playTracks,
|
||||||
|
}) : _api = api,
|
||||||
|
_database = database,
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
/// Fetch queue for a player from the API.
|
||||||
|
///
|
||||||
|
/// Handles:
|
||||||
|
/// - Group child players (fetches leader's queue)
|
||||||
|
/// - Cast-to-Sendspin ID translation
|
||||||
|
/// - Automatic caching to database
|
||||||
|
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||||
|
final effectivePlayerId = _getEffectivePlayerId(playerId);
|
||||||
|
|
||||||
|
final queue = await _api?.getQueue(effectivePlayerId);
|
||||||
|
|
||||||
|
// Persist queue to database for instant display on app resume
|
||||||
|
if (queue != null) {
|
||||||
|
persistQueueToDatabase(playerId, queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached queue for instant display (before API refresh).
|
||||||
|
Future<PlayerQueue?> getCachedQueue(String playerId) async {
|
||||||
|
try {
|
||||||
|
if (!_database.isInitialized) return null;
|
||||||
|
|
||||||
|
final cachedItems = await _database.getCachedQueue(playerId);
|
||||||
|
if (cachedItems.isEmpty) return null;
|
||||||
|
|
||||||
|
final items = <QueueItem>[];
|
||||||
|
for (final cached in cachedItems) {
|
||||||
|
try {
|
||||||
|
final itemData = jsonDecode(cached.itemJson) as Map<String, dynamic>;
|
||||||
|
items.add(QueueItem.fromJson(itemData));
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error parsing cached queue item: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty) return null;
|
||||||
|
|
||||||
|
return PlayerQueue(
|
||||||
|
playerId: playerId,
|
||||||
|
items: items,
|
||||||
|
currentIndex: 0, // Will be updated from fresh data
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error loading cached queue: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist queue to database for app restart persistence.
|
||||||
|
void persistQueueToDatabase(String playerId, PlayerQueue queue) {
|
||||||
|
() async {
|
||||||
|
try {
|
||||||
|
if (!_database.isInitialized) return;
|
||||||
|
|
||||||
|
final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList();
|
||||||
|
await _database.saveQueue(playerId, itemJsonList);
|
||||||
|
_logger.log('💾 Persisted ${queue.items.length} queue items to database');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error persisting queue to database: $e');
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to restore the queue from cached data and start playback.
|
||||||
|
Future<bool> restoreQueueFromCache(String playerId) async {
|
||||||
|
try {
|
||||||
|
final cachedQueue = await getCachedQueue(playerId);
|
||||||
|
if (cachedQueue == null || cachedQueue.items.isEmpty) {
|
||||||
|
_logger.log('⚠️ No cached queue to restore');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate tracks from queue items
|
||||||
|
final tracks = _validateQueueTracks(cachedQueue.items);
|
||||||
|
|
||||||
|
if (tracks.isEmpty) {
|
||||||
|
_logger.log('⚠️ Cached queue has no valid tracks');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🔄 Restoring ${tracks.length} tracks from cached queue');
|
||||||
|
|
||||||
|
// Re-queue all tracks and start playback
|
||||||
|
await playTracks(playerId, tracks, startIndex: 0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Error restoring queue from cache: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate and filter tracks from queue items.
|
||||||
|
///
|
||||||
|
/// Returns only tracks that can be played (have available provider mappings
|
||||||
|
/// or valid provider/itemId combination).
|
||||||
|
List<Track> _validateQueueTracks(List<QueueItem> queueItems) {
|
||||||
|
return queueItems
|
||||||
|
.map((item) => item.track)
|
||||||
|
.where((track) {
|
||||||
|
// Check for providerMappings with at least one available entry
|
||||||
|
if (track.providerMappings != null && track.providerMappings!.isNotEmpty) {
|
||||||
|
return track.providerMappings!.any((m) => m.available);
|
||||||
|
}
|
||||||
|
// Fallback: provider + itemId can be used to construct URI
|
||||||
|
return track.provider.isNotEmpty && track.itemId.isNotEmpty;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the effective player ID for queue operations.
|
||||||
|
///
|
||||||
|
/// Handles:
|
||||||
|
/// - Group child players (returns leader's ID)
|
||||||
|
/// - Cast-to-Sendspin ID translation
|
||||||
|
String _getEffectivePlayerId(String playerId) {
|
||||||
|
String effectivePlayerId = playerId;
|
||||||
|
final availablePlayers = getAvailablePlayers();
|
||||||
|
final castToSendspinMap = getCastToSendspinMap();
|
||||||
|
|
||||||
|
final player = availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == playerId,
|
||||||
|
orElse: () => Player(
|
||||||
|
playerId: playerId,
|
||||||
|
name: '',
|
||||||
|
available: false,
|
||||||
|
powered: false,
|
||||||
|
state: 'idle',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If this player is a group child, fetch the leader's queue instead
|
||||||
|
// This ensures grouped players show the same queue as their leader
|
||||||
|
if (player.isGroupChild && player.syncedTo != null) {
|
||||||
|
_logger.log('🔗 Player $playerId is grouped, fetching leader queue: ${player.syncedTo}');
|
||||||
|
effectivePlayerId = player.syncedTo!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate Sendspin ID to Cast UUID for queue fetch
|
||||||
|
// MA stores queues under the Cast UUID, not the Sendspin ID
|
||||||
|
effectivePlayerId = _translateSendspinToCastId(effectivePlayerId, availablePlayers, castToSendspinMap);
|
||||||
|
|
||||||
|
return effectivePlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate Sendspin ID to Cast UUID for queue operations.
|
||||||
|
///
|
||||||
|
/// MA stores queues under the Cast UUID, not the Sendspin ID.
|
||||||
|
/// Format: cast-7df484e3 -> need Cast UUID starting with 7df484e3
|
||||||
|
String _translateSendspinToCastId(
|
||||||
|
String playerId,
|
||||||
|
List<Player> availablePlayers,
|
||||||
|
Map<String, String> castToSendspinMap,
|
||||||
|
) {
|
||||||
|
if (!playerId.startsWith('cast-') || playerId.length < 13) {
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefix = playerId.substring(5); // Remove "cast-"
|
||||||
|
|
||||||
|
// Reverse lookup in the map
|
||||||
|
for (final entry in castToSendspinMap.entries) {
|
||||||
|
if (entry.value == playerId) {
|
||||||
|
_logger.log('🔗 Translated Sendspin ID $playerId to Cast UUID ${entry.key} for queue fetch');
|
||||||
|
return entry.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in map, try to find in available players
|
||||||
|
for (final p in availablePlayers) {
|
||||||
|
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
|
||||||
|
_logger.log('🔗 Found Cast UUID ${p.playerId} for Sendspin ID $playerId via player lookup');
|
||||||
|
return p.playerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear cached queue for a player.
|
||||||
|
Future<void> clearCachedQueue(String playerId) async {
|
||||||
|
await _database.clearCachedQueue(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../services/cache_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/music_assistant_api.dart';
|
||||||
|
|
||||||
|
/// Service for handling search operations with caching.
|
||||||
|
///
|
||||||
|
/// Extracted from MusicAssistantProvider to isolate search logic.
|
||||||
|
class SearchService {
|
||||||
|
final MusicAssistantAPI? _api;
|
||||||
|
final CacheService _cacheService;
|
||||||
|
final DebugLogger _logger;
|
||||||
|
|
||||||
|
SearchService({
|
||||||
|
required MusicAssistantAPI? api,
|
||||||
|
required CacheService cacheService,
|
||||||
|
required DebugLogger logger,
|
||||||
|
}) : _api = api,
|
||||||
|
_cacheService = cacheService,
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
/// Search with caching support.
|
||||||
|
///
|
||||||
|
/// Returns cached results if available and not expired.
|
||||||
|
/// Otherwise fetches from API and caches the results.
|
||||||
|
Future<Map<String, List<MediaItem>>> searchWithCache(
|
||||||
|
String query, {
|
||||||
|
bool forceRefresh = false,
|
||||||
|
bool libraryOnly = false,
|
||||||
|
}) async {
|
||||||
|
final baseKey = query.toLowerCase().trim();
|
||||||
|
if (baseKey.isEmpty) {
|
||||||
|
return _emptySearchResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include libraryOnly in cache key to separate results
|
||||||
|
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
|
||||||
|
|
||||||
|
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached search results for "$query" (libraryOnly: $libraryOnly)');
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) {
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey) ?? _emptySearchResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Searching for "$query" (libraryOnly: $libraryOnly)...');
|
||||||
|
final results = await _api!.search(query, libraryOnly: libraryOnly);
|
||||||
|
|
||||||
|
final cachedResults = <String, List<MediaItem>>{
|
||||||
|
'artists': results['artists'] ?? [],
|
||||||
|
'albums': results['albums'] ?? [],
|
||||||
|
'tracks': results['tracks'] ?? [],
|
||||||
|
'playlists': results['playlists'] ?? [],
|
||||||
|
'audiobooks': results['audiobooks'] ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||||
|
_logger.log('✅ Cached search results for "$query"');
|
||||||
|
return cachedResults;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Search failed: $e');
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey) ?? _emptySearchResults();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all search and detail caches.
|
||||||
|
void clearAllDetailCaches() {
|
||||||
|
_cacheService.clearAllDetailCaches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if search cache is valid for a query.
|
||||||
|
bool isSearchCacheValid(String query, {bool forceRefresh = false}) {
|
||||||
|
final baseKey = query.toLowerCase().trim();
|
||||||
|
return _cacheService.isSearchCacheValid(baseKey, forceRefresh: forceRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached search results for a query.
|
||||||
|
Map<String, List<MediaItem>>? getCachedSearchResults(String query, {bool libraryOnly = false}) {
|
||||||
|
final baseKey = query.toLowerCase().trim();
|
||||||
|
final cacheKey = libraryOnly ? '$baseKey:library' : baseKey;
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<MediaItem>> _emptySearchResults() {
|
||||||
|
return {
|
||||||
|
'artists': [],
|
||||||
|
'albums': [],
|
||||||
|
'tracks': [],
|
||||||
|
'playlists': [],
|
||||||
|
'audiobooks': [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// Utility methods for network-related operations.
|
||||||
|
///
|
||||||
|
/// Extracted from MusicAssistantProvider to isolate network logic.
|
||||||
|
class NetworkUtils {
|
||||||
|
/// Check if a hostname is a local/private network address.
|
||||||
|
///
|
||||||
|
/// Returns true for:
|
||||||
|
/// - Private IPv4 ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
|
||||||
|
/// - localhost (127.x.x.x)
|
||||||
|
/// - .local domains (mDNS/Bonjour)
|
||||||
|
/// - .ts.net domains (Tailscale VPN - treat as local since it's a VPN)
|
||||||
|
static bool isLocalNetworkHost(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'); // Tailscale - treat as local since it's a VPN
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import '../models/player.dart';
|
||||||
|
|
||||||
|
/// Utility for player list sorting and filtering operations.
|
||||||
|
///
|
||||||
|
/// Extracted from MusicAssistantProvider to isolate player sorting logic.
|
||||||
|
class PlayerSortUtility {
|
||||||
|
/// Sort players list based on smart sort setting.
|
||||||
|
///
|
||||||
|
/// Smart sort order: local player first, then playing, then on, then off.
|
||||||
|
/// Within same status, sort alphabetically.
|
||||||
|
///
|
||||||
|
/// Default sort: alphabetical only.
|
||||||
|
static void sortPlayers(
|
||||||
|
List<Player> players, {
|
||||||
|
required bool smartSort,
|
||||||
|
String? builtinPlayerId,
|
||||||
|
}) {
|
||||||
|
if (smartSort) {
|
||||||
|
// Smart sort: local player first, then playing, then on, then off
|
||||||
|
players.sort((a, b) {
|
||||||
|
// Local player always first
|
||||||
|
final aIsLocal = builtinPlayerId != null && a.playerId == builtinPlayerId;
|
||||||
|
final bIsLocal = builtinPlayerId != null && b.playerId == builtinPlayerId;
|
||||||
|
if (aIsLocal && !bIsLocal) return -1;
|
||||||
|
if (bIsLocal && !aIsLocal) return 1;
|
||||||
|
|
||||||
|
// Then by status: playing > on > off
|
||||||
|
final aPriority = _statusPriority(a);
|
||||||
|
final bPriority = _statusPriority(b);
|
||||||
|
if (aPriority != bPriority) return aPriority.compareTo(bPriority);
|
||||||
|
|
||||||
|
// Within same status, sort alphabetically
|
||||||
|
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Default alphabetical sort
|
||||||
|
players.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get priority for player status (lower = higher priority).
|
||||||
|
static int _statusPriority(Player player) {
|
||||||
|
if (player.state == 'playing') return 0;
|
||||||
|
if (player.powered && player.state != 'off') return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter players to exclude unwanted players.
|
||||||
|
///
|
||||||
|
/// Removes:
|
||||||
|
/// - "Music Assistant Mobile" players
|
||||||
|
/// - MA Web UI's built-in player (ma_ prefix)
|
||||||
|
/// - "This Device" named players without proper provider
|
||||||
|
/// - Other device's ensemble players
|
||||||
|
/// - Unavailable players (except builtin player)
|
||||||
|
static List<Player> filterPlayers(
|
||||||
|
List<Player> players, {
|
||||||
|
required String? builtinPlayerId,
|
||||||
|
}) {
|
||||||
|
final result = <Player>[];
|
||||||
|
|
||||||
|
for (final player in players) {
|
||||||
|
final nameLower = player.name.toLowerCase();
|
||||||
|
|
||||||
|
// Filter out "music assistant mobile" players
|
||||||
|
if (nameLower.contains('music assistant mobile')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
|
||||||
|
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out "This Device" named players without proper provider
|
||||||
|
if (nameLower == 'this device') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out other device's ensemble players
|
||||||
|
if (player.playerId.startsWith('ensemble_')) {
|
||||||
|
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out unavailable players (except builtin player)
|
||||||
|
if (!player.available) {
|
||||||
|
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
||||||
|
result.add(player);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.add(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the best player to auto-select based on priority.
|
||||||
|
///
|
||||||
|
/// Priority order:
|
||||||
|
/// 1. Last selected player (if available)
|
||||||
|
/// 2. Last active player from any session
|
||||||
|
/// 3. Built-in player
|
||||||
|
/// 4. First available player
|
||||||
|
static Player? selectBestPlayer({
|
||||||
|
required List<Player> availablePlayers,
|
||||||
|
required Player? lastSelectedPlayer,
|
||||||
|
required Player? lastActivePlayer,
|
||||||
|
required String? builtinPlayerId,
|
||||||
|
}) {
|
||||||
|
// Priority 1: Last selected player (if available and in current list)
|
||||||
|
if (lastSelectedPlayer != null) {
|
||||||
|
final found = availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == lastSelectedPlayer!.playerId,
|
||||||
|
orElse: () => _emptyPlayer,
|
||||||
|
);
|
||||||
|
if (found.playerId.isNotEmpty) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Last active player from any session (if available)
|
||||||
|
if (lastActivePlayer != null) {
|
||||||
|
final found = availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == lastActivePlayer!.playerId,
|
||||||
|
orElse: () => _emptyPlayer,
|
||||||
|
);
|
||||||
|
if (found.playerId.isNotEmpty) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Built-in player (if available)
|
||||||
|
if (builtinPlayerId != null) {
|
||||||
|
final builtin = availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == builtinPlayerId,
|
||||||
|
orElse: () => _emptyPlayer,
|
||||||
|
);
|
||||||
|
if (builtin.playerId.isNotEmpty) {
|
||||||
|
return builtin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 4: First available player
|
||||||
|
if (availablePlayers.isNotEmpty) {
|
||||||
|
return availablePlayers.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final Player _emptyPlayer = Player(
|
||||||
|
playerId: '',
|
||||||
|
name: '',
|
||||||
|
available: false,
|
||||||
|
powered: false,
|
||||||
|
state: 'idle',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import '../models/player.dart';
|
||||||
|
|
||||||
|
/// Utility for determining player sync state.
|
||||||
|
///
|
||||||
|
/// Extracted from MusicAssistantProvider to isolate complex sync logic
|
||||||
|
/// and make it testable independently.
|
||||||
|
class PlayerSyncState {
|
||||||
|
final List<Player> availablePlayers;
|
||||||
|
final Map<String, String> castToSendspinIdMap;
|
||||||
|
|
||||||
|
PlayerSyncState({
|
||||||
|
required this.availablePlayers,
|
||||||
|
required this.castToSendspinIdMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if a player should show the "manually synced" indicator (yellow border).
|
||||||
|
///
|
||||||
|
/// Returns true for BOTH the leader AND children of a manually created sync group.
|
||||||
|
/// Excludes pre-configured MA speaker groups (provider = 'player_group').
|
||||||
|
bool isPlayerManuallySynced(String playerId) {
|
||||||
|
final player = availablePlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||||
|
if (player == null) return false;
|
||||||
|
|
||||||
|
// Group players (like "All Speakers") should NEVER have yellow border
|
||||||
|
// They are pre-configured containers, not manually synced players
|
||||||
|
// Check this FIRST before any other logic to prevent edge cases
|
||||||
|
if (player.provider == 'player_group') return false;
|
||||||
|
|
||||||
|
// Case 1: Player is a child synced to another player
|
||||||
|
if (player.syncedTo != null) {
|
||||||
|
// Look up sync target - also check translated IDs for Cast+Sendspin players
|
||||||
|
// The syncedTo might contain a Cast ID but the player list has the Sendspin version
|
||||||
|
Player? syncTarget = availablePlayers.where((p) => p.playerId == player.syncedTo).firstOrNull;
|
||||||
|
|
||||||
|
// If not found, try looking up by translated Sendspin ID
|
||||||
|
if (syncTarget == null) {
|
||||||
|
final translatedId = castToSendspinIdMap[player.syncedTo];
|
||||||
|
if (translatedId != null) {
|
||||||
|
syncTarget = availablePlayers.where((p) => p.playerId == translatedId).firstOrNull;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check reverse: syncedTo might be Sendspin ID, look for Cast player
|
||||||
|
if (syncTarget == null) {
|
||||||
|
// Build reverse map on demand
|
||||||
|
for (final entry in castToSendspinIdMap.entries) {
|
||||||
|
if (entry.value == player.syncedTo) {
|
||||||
|
syncTarget = availablePlayers.where((p) => p.playerId == entry.key).firstOrNull;
|
||||||
|
if (syncTarget != null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncTarget == null) return false;
|
||||||
|
|
||||||
|
// If synced to a group player, it's part of a pre-configured group
|
||||||
|
if (syncTarget.provider == 'player_group') return false;
|
||||||
|
|
||||||
|
// Synced to a regular player - this is a manual sync child
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Player is a leader with group members
|
||||||
|
if (player.groupMembers != null && player.groupMembers!.length > 1) {
|
||||||
|
// Key distinction: In a MANUAL sync, the leader's own ID is in groupMembers
|
||||||
|
// In a PRE-CONFIGURED group (UGP), the group player's ID is NOT in groupMembers
|
||||||
|
// (the members are the child players, not including the group itself)
|
||||||
|
final isInOwnGroup = player.groupMembers!.contains(player.playerId);
|
||||||
|
if (!isInOwnGroup) {
|
||||||
|
// This is a pre-configured group player (like "All Speakers")
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Leader's ID is in groupMembers = manual sync
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a player by ID, optionally translating Cast IDs to Sendspin IDs.
|
||||||
|
Player? findPlayer(String playerId) {
|
||||||
|
// Try direct lookup first
|
||||||
|
var player = availablePlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||||
|
|
||||||
|
// If not found, try Cast-to-Sendspin translation
|
||||||
|
if (player == null && castToSendspinIdMap.containsKey(playerId)) {
|
||||||
|
final sendspinId = castToSendspinIdMap[playerId];
|
||||||
|
player = availablePlayers.where((p) => p.playerId == sendspinId).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the sync target for a player, handling Cast/Sendspin ID translation.
|
||||||
|
Player? findSyncTarget(Player player) {
|
||||||
|
if (player.syncedTo == null) return null;
|
||||||
|
|
||||||
|
// Try direct lookup
|
||||||
|
var syncTarget = availablePlayers.where((p) => p.playerId == player.syncedTo).firstOrNull;
|
||||||
|
|
||||||
|
// If not found, try translated Sendspin ID
|
||||||
|
if (syncTarget == null) {
|
||||||
|
final translatedId = castToSendspinIdMap[player.syncedTo];
|
||||||
|
if (translatedId != null) {
|
||||||
|
syncTarget = availablePlayers.where((p) => p.playerId == translatedId).firstOrNull;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check reverse: syncedTo might be Sendspin ID, look for Cast player
|
||||||
|
if (syncTarget == null) {
|
||||||
|
for (final entry in castToSendspinIdMap.entries) {
|
||||||
|
if (entry.value == player.syncedTo) {
|
||||||
|
syncTarget = availablePlayers.where((p) => p.playerId == entry.key).firstOrNull;
|
||||||
|
if (syncTarget != null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a player is a group player (pre-configured MA speaker group).
|
||||||
|
bool isGroupPlayer(Player player) {
|
||||||
|
return player.provider == 'player_group';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a player is a leader in a manual sync group.
|
||||||
|
bool isManualSyncLeader(Player player) {
|
||||||
|
if (player.groupMembers == null || player.groupMembers!.length <= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// In a MANUAL sync, the leader's own ID is in groupMembers
|
||||||
|
return player.groupMembers!.contains(player.playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import '../constants/timings.dart';
|
||||||
|
|
||||||
|
/// Utility methods for queue playback controls.
|
||||||
|
///
|
||||||
|
/// Extracted from MusicAssistantProvider to isolate queue control logic.
|
||||||
|
class QueueControls {
|
||||||
|
/// Get the next repeat mode in the cycle.
|
||||||
|
///
|
||||||
|
/// Cycle order: off → all → one → off
|
||||||
|
static String getNextRepeatMode(String? currentMode) {
|
||||||
|
switch (currentMode) {
|
||||||
|
case RepeatModes.off:
|
||||||
|
case null:
|
||||||
|
return RepeatModes.all;
|
||||||
|
case RepeatModes.all:
|
||||||
|
return RepeatModes.one;
|
||||||
|
case RepeatModes.one:
|
||||||
|
return RepeatModes.off;
|
||||||
|
default:
|
||||||
|
return RepeatModes.off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a repeat mode is valid.
|
||||||
|
static bool isValidRepeatMode(String? mode) {
|
||||||
|
return mode == RepeatModes.off ||
|
||||||
|
mode == RepeatModes.all ||
|
||||||
|
mode == RepeatModes.one;
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-20
@@ -63,10 +63,10 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
|
|||||||
final service = LibraryStatusService.instance;
|
final service = LibraryStatusService.instance;
|
||||||
final key = libraryItemKey;
|
final key = libraryItemKey;
|
||||||
if (!service.isInLibrary(key) && widget.album.inLibrary) {
|
if (!service.isInLibrary(key) && widget.album.inLibrary) {
|
||||||
service.setLibraryStatus(key, true);
|
service.initLibraryStatus(key, true);
|
||||||
}
|
}
|
||||||
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
|
||||||
service.setFavoriteStatus(key, true);
|
service.initFavoriteStatus(key, true);
|
||||||
}
|
}
|
||||||
_initFallbackImage();
|
_initFallbackImage();
|
||||||
}
|
}
|
||||||
@@ -332,11 +332,11 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
|
|||||||
initialImageUrl: imageUrl,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).then((_) {
|
);
|
||||||
// Reset after navigation debounce delay
|
|
||||||
Future.delayed(Timings.navigationDebounce, () {
|
// Reset after navigation debounce delay
|
||||||
if (mounted) _isNavigating = false;
|
Future.delayed(Timings.navigationDebounce, () {
|
||||||
});
|
if (mounted) _isNavigating = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onLongPressStart: (details) {
|
onLongPressStart: (details) {
|
||||||
@@ -410,22 +410,26 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Album title with year
|
// Album title with year
|
||||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||||
Text(
|
Flexible(
|
||||||
widget.album.nameWithYear,
|
child: Text(
|
||||||
maxLines: 1,
|
widget.album.nameWithYear,
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Artist name
|
// Artist name
|
||||||
Text(
|
Flexible(
|
||||||
widget.album.artistsString,
|
child: Text(
|
||||||
maxLines: 1,
|
widget.album.artistsString,
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Card layout: square artwork + text below
|
// Card layout: square artwork + text below
|
||||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
// Text area: 8px gap + ~20px title + ~20px artist = ~48px
|
||||||
const textAreaHeight = 44.0;
|
const textAreaHeight = 48.0;
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
|
|||||||
@@ -63,10 +63,10 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
|
|||||||
final service = LibraryStatusService.instance;
|
final service = LibraryStatusService.instance;
|
||||||
final key = libraryItemKey;
|
final key = libraryItemKey;
|
||||||
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
|
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
|
||||||
service.setLibraryStatus(key, true);
|
service.initLibraryStatus(key, true);
|
||||||
}
|
}
|
||||||
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
|
||||||
service.setFavoriteStatus(key, true);
|
service.initFavoriteStatus(key, true);
|
||||||
}
|
}
|
||||||
// Fetch fallback image once in initState, not during build
|
// Fetch fallback image once in initState, not during build
|
||||||
_initFallbackImage();
|
_initFallbackImage();
|
||||||
@@ -318,11 +318,11 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
|
|||||||
initialImageUrl: imageUrl,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).then((_) {
|
);
|
||||||
// Reset after navigation debounce delay
|
|
||||||
Future.delayed(Timings.navigationDebounce, () {
|
// Reset after navigation debounce delay
|
||||||
if (mounted) _isNavigating = false;
|
Future.delayed(Timings.navigationDebounce, () {
|
||||||
});
|
if (mounted) _isNavigating = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onLongPressStart: (details) {
|
onLongPressStart: (details) {
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Card layout: circle image + name below
|
// Card layout: circle image + name below
|
||||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
// Text area: 8px gap + ~40px for 2-line name = ~48px
|
||||||
const textAreaHeight = 44.0;
|
const textAreaHeight = 48.0;
|
||||||
final imageSize = contentHeight - textAreaHeight;
|
final imageSize = contentHeight - textAreaHeight;
|
||||||
final cardWidth = imageSize; // Card width = image width (circle)
|
final cardWidth = imageSize; // Card width = image width (circle)
|
||||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const textAreaHeight = 44.0;
|
const textAreaHeight = 48.0;
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
final cardWidth = artworkSize;
|
final cardWidth = artworkSize;
|
||||||
final itemExtent = cardWidth + 12;
|
final itemExtent = cardWidth + 12;
|
||||||
@@ -223,10 +223,10 @@ class _AudiobookCardState extends State<_AudiobookCard> with LibraryStatusMixin
|
|||||||
final service = LibraryStatusService.instance;
|
final service = LibraryStatusService.instance;
|
||||||
final key = libraryItemKey;
|
final key = libraryItemKey;
|
||||||
if (!service.isInLibrary(key) && widget.audiobook.inLibrary) {
|
if (!service.isInLibrary(key) && widget.audiobook.inLibrary) {
|
||||||
service.setLibraryStatus(key, true);
|
service.initLibraryStatus(key, true);
|
||||||
}
|
}
|
||||||
if (!service.isFavorite(key) && (widget.audiobook.favorite ?? false)) {
|
if (!service.isFavorite(key) && (widget.audiobook.favorite ?? false)) {
|
||||||
service.setFavoriteStatus(key, true);
|
service.initFavoriteStatus(key, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ class _DiscoveryRowState extends State<DiscoveryRow> with AutomaticKeepAliveClie
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Card layout: square artwork + text below
|
// Card layout: square artwork + text below
|
||||||
// Text area: 8px gap + ~18px title + ~18px subtitle = ~44px
|
// Text area: 8px gap + ~20px title + ~20px subtitle = ~48px
|
||||||
const textAreaHeight = 44.0;
|
const textAreaHeight = 48.0;
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
@@ -191,13 +191,15 @@ class _DiscoveryRowState extends State<DiscoveryRow> with AutomaticKeepAliveClie
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Flexible(
|
||||||
item.name,
|
child: Text(
|
||||||
style: textTheme.bodySmall?.copyWith(
|
item.name,
|
||||||
color: colorScheme.onSurface,
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -244,7 +244,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Reset expansion notifier to match initial controller state (0.0)
|
// Reset expansion notifier to match initial controller state (0.0)
|
||||||
// This fixes a bug where stale progress values from a previous widget instance
|
// This fixes a bug where stale progress values from a previous widget instance
|
||||||
// caused the nav bar to be invisible on app restart/widget recreation
|
// caused the nav bar to be invisible on app restart/widget recreation
|
||||||
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
playerExpansionNotifier.value = PlayerExpansionState(0.0, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
@@ -1641,7 +1645,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_slideController.addListener(animateToTarget);
|
_slideController.addListener(animateToTarget);
|
||||||
_slideController.duration = const Duration(milliseconds: 150);
|
_slideController.duration = const Duration(milliseconds: 150);
|
||||||
|
|
||||||
_slideController.forward().then((_) {
|
_slideController.forward().then((_) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
_slideController.removeListener(animateToTarget);
|
_slideController.removeListener(animateToTarget);
|
||||||
|
|||||||
@@ -478,10 +478,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
// PERF: Use select() to only rebuild when connection state or player changes
|
return Consumer<MusicAssistantProvider>(
|
||||||
final (isConnected, hasSelectedPlayer) = context.select<MusicAssistantProvider, (bool, bool)>(
|
builder: (context, maProvider, _) {
|
||||||
(p) => (p.isConnected, p.selectedPlayer != null),
|
final isConnected = maProvider.isConnected;
|
||||||
);
|
final hasSelectedPlayer = maProvider.selectedPlayer != null;
|
||||||
|
|
||||||
// Compute whether this is a first-time user ready for welcome screen.
|
// Compute whether this is a first-time user ready for welcome screen.
|
||||||
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
|
// This is SYNCHRONOUS - no setState needed - so it works in the same frame.
|
||||||
@@ -849,5 +849,7 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,46 +69,54 @@ class HiResBadge extends StatelessWidget {
|
|||||||
final baseColor = primaryColor ?? colorScheme.primary;
|
final baseColor = primaryColor ?? colorScheme.primary;
|
||||||
final color = Color.lerp(baseColor, Colors.white, 0.5)!;
|
final color = Color.lerp(baseColor, Colors.white, 0.5)!;
|
||||||
|
|
||||||
return Tooltip(
|
final content = Container(
|
||||||
message: tooltip ?? 'Hi-Res Audio',
|
padding: const EdgeInsets.all(2),
|
||||||
child: Container(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.all(2),
|
border: Border.all(color: color, width: 1.5),
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: Border.all(color: color, width: 1.5),
|
),
|
||||||
borderRadius: BorderRadius.circular(4),
|
child: Column(
|
||||||
),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
Text(
|
||||||
children: [
|
'Hi-Res',
|
||||||
Text(
|
style: TextStyle(
|
||||||
'Hi-Res',
|
color: color,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 1),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'AUDIO',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: color,
|
color: colorScheme.surface,
|
||||||
fontSize: 9,
|
fontSize: 8,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.bold,
|
||||||
height: 1.1,
|
height: 1.1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
),
|
||||||
margin: const EdgeInsets.only(top: 1),
|
],
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'AUDIO',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.surface,
|
|
||||||
fontSize: 8,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
height: 1.1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only use Tooltip if an Overlay ancestor exists
|
||||||
|
final hasOverlay = Overlay.maybeOf(context) != null;
|
||||||
|
if (!hasOverlay) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip ?? 'Hi-Res Audio',
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,10 +122,11 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onLibraryStatusChanged() {
|
void _onLibraryStatusChanged() {
|
||||||
// Trigger rebuild to pick up new status values
|
if (!mounted) return;
|
||||||
if (mounted) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set library status optimistically and return previous value for rollback
|
/// Set library status optimistically and return previous value for rollback
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
// Hint system
|
// Hint system
|
||||||
bool _showHints = true;
|
bool _showHints = true;
|
||||||
|
|
||||||
|
// Guard to run color preload only once from didChangeDependencies
|
||||||
|
bool _hasPreloaded = false;
|
||||||
|
|
||||||
// PERF: Pre-cached static BoxShadow to avoid allocation per frame
|
// PERF: Pre-cached static BoxShadow to avoid allocation per frame
|
||||||
static const BoxShadow _cardShadow = BoxShadow(
|
static const BoxShadow _cardShadow = BoxShadow(
|
||||||
color: Color(0x33000000), // 20% black
|
color: Color(0x33000000), // 20% black
|
||||||
@@ -80,9 +83,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Load hint settings
|
// Load hint settings
|
||||||
SettingsService.getShowHints().then((value) {
|
_loadHintSettings();
|
||||||
if (mounted) setState(() => _showHints = value);
|
|
||||||
});
|
|
||||||
|
|
||||||
_revealController = AnimationController(
|
_revealController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
@@ -100,23 +101,35 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
|
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Preload all player track info and extract colors
|
|
||||||
_preloadColorsForPlayers();
|
|
||||||
|
|
||||||
// Start reveal animation
|
// Start reveal animation
|
||||||
_revealController.duration = const Duration(milliseconds: 200);
|
_revealController.duration = const Duration(milliseconds: 200);
|
||||||
_revealController.forward();
|
_revealController.forward();
|
||||||
|
|
||||||
// Auto-refresh player data
|
// Auto-refresh player data
|
||||||
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
provider.refreshPlayers();
|
provider.refreshPlayers();
|
||||||
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
|
await provider.preloadAllPlayerTracks();
|
||||||
|
_preloadColorsForPlayers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (!_hasPreloaded) {
|
||||||
|
_hasPreloaded = true;
|
||||||
|
_preloadColorsForPlayers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHintSettings() async {
|
||||||
|
final value = await SettingsService.getShowHints();
|
||||||
|
if (mounted) setState(() => _showHints = value);
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract accent colors from album art for each player
|
/// Extract accent colors from album art for each player
|
||||||
Future<void> _preloadColorsForPlayers() async {
|
Future<void> _preloadColorsForPlayers() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
@@ -164,7 +177,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Animate dismissal and call callback when done
|
/// Animate dismissal and call callback when done
|
||||||
void dismiss() {
|
Future<void> dismiss() async {
|
||||||
// Trigger single bounce on mini player when cards are ~70% collapsed
|
// Trigger single bounce on mini player when cards are ~70% collapsed
|
||||||
// This creates the effect of cards "landing" on the mini player
|
// This creates the effect of cards "landing" on the mini player
|
||||||
bool bounceFired = false;
|
bool bounceFired = false;
|
||||||
@@ -188,10 +201,9 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
|
|
||||||
_revealController.duration = const Duration(milliseconds: 150);
|
_revealController.duration = const Duration(milliseconds: 150);
|
||||||
_revealController.addListener(checkBounce);
|
_revealController.addListener(checkBounce);
|
||||||
_revealController.reverse().then((_) {
|
await _revealController.reverse();
|
||||||
_revealController.removeListener(checkBounce);
|
_revealController.removeListener(checkBounce);
|
||||||
widget.onDismiss();
|
widget.onDismiss();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if scroll is at top (or not scrollable)
|
/// Check if scroll is at top (or not scrollable)
|
||||||
|
|||||||
@@ -207,11 +207,10 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
|||||||
_dropdownController.forward();
|
_dropdownController.forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _closeTransferDropdown() {
|
void _closeTransferDropdown() async {
|
||||||
if (!_showingTransferDropdown) return;
|
if (!_showingTransferDropdown) return;
|
||||||
_dropdownController.reverse().then((_) {
|
await _dropdownController.reverse();
|
||||||
if (mounted) setState(() => _showingTransferDropdown = false);
|
if (mounted) setState(() => _showingTransferDropdown = false);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTransferQueue(BuildContext context) {
|
void _handleTransferQueue(BuildContext context) {
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ class PlayerSelector extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Use select to only rebuild when player data changes (not on every provider update)
|
|
||||||
final (selectedPlayer, availablePlayers) = context.select<MusicAssistantProvider, (dynamic, List)>(
|
|
||||||
(p) => (p.selectedPlayer, p.availablePlayers),
|
|
||||||
);
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Consumer<MusicAssistantProvider>(
|
||||||
|
builder: (context, maProvider, _) {
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
final availablePlayers = maProvider.availablePlayers;
|
||||||
|
|
||||||
// Count players that are currently playing MA content (excluding selected player)
|
// Count players that are currently playing MA content (excluding selected player)
|
||||||
// User already sees if selected player is playing, badge shows "other" activity
|
// User already sees if selected player is playing, badge shows "other" activity
|
||||||
// Exclude external sources (Spotify Connect, TV optical, etc.) - they're not playing MA content
|
// Exclude external sources (Spotify Connect, TV optical, etc.) - they're not playing MA content
|
||||||
@@ -80,6 +81,8 @@ class PlayerSelector extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPlayerSelector(BuildContext context) {
|
void _showPlayerSelector(BuildContext context) {
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
|
|||||||
final service = LibraryStatusService.instance;
|
final service = LibraryStatusService.instance;
|
||||||
final key = libraryItemKey;
|
final key = libraryItemKey;
|
||||||
if (!service.isInLibrary(key) && widget.playlist.inLibrary) {
|
if (!service.isInLibrary(key) && widget.playlist.inLibrary) {
|
||||||
service.setLibraryStatus(key, true);
|
service.initLibraryStatus(key, true);
|
||||||
}
|
}
|
||||||
if (!service.isFavorite(key) && (widget.playlist.favorite ?? false)) {
|
if (!service.isFavorite(key) && (widget.playlist.favorite ?? false)) {
|
||||||
service.setFavoriteStatus(key, true);
|
service.initFavoriteStatus(key, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,11 +255,11 @@ class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
|
|||||||
initialImageUrl: imageUrl,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).then((_) {
|
);
|
||||||
// Reset after navigation debounce delay
|
|
||||||
Future.delayed(Timings.navigationDebounce, () {
|
// Reset after navigation debounce delay
|
||||||
if (mounted) _isNavigating = false;
|
Future.delayed(Timings.navigationDebounce, () {
|
||||||
});
|
if (mounted) _isNavigating = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onLongPressStart: (details) {
|
onLongPressStart: (details) {
|
||||||
@@ -327,22 +327,26 @@ class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Playlist title
|
// Playlist title
|
||||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||||
Text(
|
Flexible(
|
||||||
widget.playlist.name,
|
child: Text(
|
||||||
maxLines: 1,
|
widget.playlist.name,
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Owner/track count
|
// Owner/track count
|
||||||
Text(
|
Flexible(
|
||||||
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
|
child: Text(
|
||||||
maxLines: 1,
|
widget.playlist.owner ?? (widget.playlist.trackCount != null ? '${widget.playlist.trackCount} tracks' : ''),
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Card layout: square artwork + text below (same as AlbumRow)
|
// Card layout: square artwork + text below (same as AlbumRow)
|
||||||
// Text area: 8px gap + ~18px title + ~18px owner = ~44px
|
// Text area: 8px gap + ~20px title + ~20px owner = ~48px
|
||||||
const textAreaHeight = 44.0;
|
const textAreaHeight = 48.0;
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
|
|||||||
final service = LibraryStatusService.instance;
|
final service = LibraryStatusService.instance;
|
||||||
final key = libraryItemKey;
|
final key = libraryItemKey;
|
||||||
if (!service.isInLibrary(key) && widget.podcast.inLibrary) {
|
if (!service.isInLibrary(key) && widget.podcast.inLibrary) {
|
||||||
service.setLibraryStatus(key, true);
|
service.initLibraryStatus(key, true);
|
||||||
}
|
}
|
||||||
if (!service.isFavorite(key) && (widget.podcast.favorite ?? false)) {
|
if (!service.isFavorite(key) && (widget.podcast.favorite ?? false)) {
|
||||||
service.setFavoriteStatus(key, true);
|
service.initFavoriteStatus(key, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +261,11 @@ class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
|
|||||||
initialImageUrl: imageUrl,
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).then((_) {
|
);
|
||||||
// Reset after navigation debounce delay
|
|
||||||
Future.delayed(Timings.navigationDebounce, () {
|
// Reset after navigation debounce delay
|
||||||
if (mounted) _isNavigating = false;
|
Future.delayed(Timings.navigationDebounce, () {
|
||||||
});
|
if (mounted) _isNavigating = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onLongPressStart: (details) {
|
onLongPressStart: (details) {
|
||||||
@@ -333,22 +333,26 @@ class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Podcast title
|
// Podcast title
|
||||||
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
|
||||||
Text(
|
Flexible(
|
||||||
widget.podcast.name,
|
child: Text(
|
||||||
maxLines: 1,
|
widget.podcast.name,
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Podcast author (if available)
|
// Podcast author (if available)
|
||||||
Text(
|
Flexible(
|
||||||
_getAuthor(widget.podcast),
|
child: Text(
|
||||||
maxLines: 1,
|
_getAuthor(widget.podcast),
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Card layout: square artwork + text below (same as AlbumRow)
|
// Card layout: square artwork + text below (same as AlbumRow)
|
||||||
// Text area: 8px gap + ~18px title + ~18px author = ~44px
|
// Text area: 8px gap + ~20px title + ~20px author = ~48px
|
||||||
const textAreaHeight = 44.0;
|
const textAreaHeight = 48.0;
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ class _RadioStationCardState extends State<RadioStationCard> with LibraryStatusM
|
|||||||
final service = LibraryStatusService.instance;
|
final service = LibraryStatusService.instance;
|
||||||
final key = libraryItemKey;
|
final key = libraryItemKey;
|
||||||
if (!service.isInLibrary(key) && widget.radioStation.inLibrary) {
|
if (!service.isInLibrary(key) && widget.radioStation.inLibrary) {
|
||||||
service.setLibraryStatus(key, true);
|
service.initLibraryStatus(key, true);
|
||||||
}
|
}
|
||||||
if (!service.isFavorite(key) && (widget.radioStation.favorite ?? false)) {
|
if (!service.isFavorite(key) && (widget.radioStation.favorite ?? false)) {
|
||||||
service.setFavoriteStatus(key, true);
|
service.initFavoriteStatus(key, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Card layout: circle image + name below (same as ArtistRow)
|
// Card layout: circle image + name below (same as ArtistRow)
|
||||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
// Text area: 8px gap + ~40px for 2-line name = ~48px
|
||||||
const textAreaHeight = 44.0;
|
const textAreaHeight = 48.0;
|
||||||
final imageSize = contentHeight - textAreaHeight;
|
final imageSize = contentHeight - textAreaHeight;
|
||||||
final cardWidth = imageSize; // Card width = image width (circle)
|
final cardWidth = imageSize; // Card width = image width (circle)
|
||||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const textAreaHeight = 44.0;
|
const textAreaHeight = 48.0;
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
final cardWidth = artworkSize;
|
final cardWidth = artworkSize;
|
||||||
final itemExtent = cardWidth + 12;
|
final itemExtent = cardWidth + 12;
|
||||||
|
|||||||
+21
-17
@@ -108,8 +108,8 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Card layout: square artwork + text below
|
// Card layout: square artwork + text below
|
||||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
// Text area: 8px gap + ~20px title + ~20px artist = ~48px
|
||||||
const textAreaHeight = 44.0;
|
const textAreaHeight = 48.0;
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
final itemExtent = cardWidth + 12;
|
final itemExtent = cardWidth + 12;
|
||||||
@@ -211,10 +211,10 @@ class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
|
|||||||
final service = LibraryStatusService.instance;
|
final service = LibraryStatusService.instance;
|
||||||
final key = libraryItemKey;
|
final key = libraryItemKey;
|
||||||
if (!service.isInLibrary(key) && widget.track.inLibrary) {
|
if (!service.isInLibrary(key) && widget.track.inLibrary) {
|
||||||
service.setLibraryStatus(key, true);
|
service.initLibraryStatus(key, true);
|
||||||
}
|
}
|
||||||
if (!service.isFavorite(key) && (widget.track.favorite ?? false)) {
|
if (!service.isFavorite(key) && (widget.track.favorite ?? false)) {
|
||||||
service.setFavoriteStatus(key, true);
|
service.initFavoriteStatus(key, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,22 +453,26 @@ class _TrackCardState extends State<_TrackCard> with LibraryStatusMixin {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Track title
|
// Track title
|
||||||
Text(
|
Flexible(
|
||||||
widget.track.name,
|
child: Text(
|
||||||
maxLines: 1,
|
widget.track.name,
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface,
|
style: textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Artist name
|
// Artist name
|
||||||
Text(
|
Flexible(
|
||||||
widget.track.artistsString,
|
child: Text(
|
||||||
maxLines: 1,
|
widget.track.artistsString,
|
||||||
overflow: TextOverflow.ellipsis,
|
maxLines: 1,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user