Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e27e6a4c2 | ||
|
|
b136b26b7e | ||
|
|
404828c47c | ||
|
|
48afbf9d50 | ||
|
|
ebf4e53328 | ||
|
|
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,212 @@
|
||||
# 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 Complete**: Integration into main.dart completed
|
||||
- All providers added to MultiProvider tree
|
||||
- Shared services (CacheService, PositionTracker, ImageHelperService) properly initialized
|
||||
- QueueManagerService circular dependency resolved
|
||||
- Build successful (debug APK tested)
|
||||
✅ **Critical Bug Fixed**: Red screen after login resolved (commit: ebf4e53)
|
||||
- Fixed setState during build error in LibraryStatusMixin (using Future.microtask)
|
||||
- Fixed context.select inside PageView.builder error (replaced with context.watch)
|
||||
|
||||
### 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)
|
||||
static const Duration playerPollingInterval = Duration(seconds: 5);
|
||||
|
||||
/// Notification position update interval (for updating playback position in notification)
|
||||
static const Duration notificationPositionUpdate = Duration(seconds: 1);
|
||||
|
||||
/// Local player state report interval
|
||||
/// Note: Sendspin protocol only requires state on changes, not periodic reports.
|
||||
/// This is mainly for local UI position tracking. Set to 5s to reduce server traffic.
|
||||
@@ -54,3 +57,62 @@ class LibraryConstants {
|
||||
/// Default limit for recent/random items
|
||||
static const int defaultRecentLimit = 10;
|
||||
}
|
||||
|
||||
/// Image/artwork size constants
|
||||
class ImageSizes {
|
||||
ImageSizes._();
|
||||
|
||||
/// Default image size for artwork (256x256)
|
||||
static const int defaultSize = 256;
|
||||
|
||||
/// High resolution image size for player/overlay (512x512)
|
||||
static const int highRes = 512;
|
||||
|
||||
/// Maximum image size for large displays
|
||||
static const int max = 1024;
|
||||
}
|
||||
|
||||
/// Volume control constants
|
||||
class VolumeConstants {
|
||||
VolumeConstants._();
|
||||
|
||||
/// Minimum volume level
|
||||
static const int min = 0;
|
||||
|
||||
/// Maximum volume level
|
||||
static const int max = 100;
|
||||
|
||||
/// Volume range for validation (0-100)
|
||||
static const int range = 100;
|
||||
}
|
||||
|
||||
/// Player state constants
|
||||
class PlayerStates {
|
||||
PlayerStates._();
|
||||
|
||||
/// Player is playing media
|
||||
static const String playing = 'playing';
|
||||
|
||||
/// Player is paused
|
||||
static const String paused = 'paused';
|
||||
|
||||
/// Player is idle/nothing loaded
|
||||
static const String idle = 'idle';
|
||||
|
||||
/// Player is off/disconnected
|
||||
static const String off = 'off';
|
||||
}
|
||||
|
||||
/// Repeat mode constants
|
||||
class RepeatModes {
|
||||
RepeatModes._();
|
||||
|
||||
/// Repeat is off
|
||||
static const String off = 'off';
|
||||
|
||||
/// Repeat all tracks
|
||||
static const String all = 'all';
|
||||
|
||||
/// Repeat single track
|
||||
static const String one = 'one';
|
||||
}
|
||||
|
||||
+200
-53
@@ -6,14 +6,25 @@ import 'package:provider/provider.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'providers/connection_provider.dart';
|
||||
import 'providers/library_provider.dart';
|
||||
import 'providers/locale_provider.dart';
|
||||
import 'providers/local_player_provider.dart';
|
||||
import 'providers/music_assistant_provider.dart';
|
||||
import 'providers/navigation_provider.dart';
|
||||
import 'providers/player_provider.dart';
|
||||
import 'providers/sleep_timer_provider.dart';
|
||||
import 'providers/ui_state_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'services/cache_service.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'services/database_service.dart';
|
||||
import 'services/image_helper_service.dart';
|
||||
import 'services/position_tracker.dart';
|
||||
import 'services/queue_manager_service.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'services/provider_filter_service.dart';
|
||||
import 'services/sync_service.dart';
|
||||
import 'services/audio/massiv_audio_handler.dart';
|
||||
import 'services/auth/auth_manager.dart';
|
||||
@@ -23,6 +34,7 @@ import 'services/music_assistant_api.dart' show MAConnectionState;
|
||||
import 'theme/theme_provider.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'theme/system_theme_helper.dart';
|
||||
import 'utils/player_sync_state.dart';
|
||||
import 'widgets/global_player_overlay.dart';
|
||||
|
||||
// Global audio handler instance
|
||||
@@ -125,6 +137,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
late MusicAssistantProvider _musicProvider;
|
||||
late ThemeProvider _themeProvider;
|
||||
late LocaleProvider _localeProvider;
|
||||
|
||||
// New multi-provider architecture
|
||||
late ConnectionProvider _connectionProvider;
|
||||
late UIStateProvider _uiStateProvider;
|
||||
late LibraryProvider _libraryProvider;
|
||||
late PlayerProvider _playerProvider;
|
||||
late LocalPlayerProvider _localPlayerProvider;
|
||||
late SleepTimerProvider _sleepTimerProvider;
|
||||
late NavigationProvider _navigationProvider;
|
||||
|
||||
// Shared services
|
||||
late final CacheService _cacheService = CacheService();
|
||||
late final PositionTracker _positionTracker = PositionTracker();
|
||||
late ImageHelperService _imageHelper;
|
||||
|
||||
final _hardwareVolumeService = HardwareVolumeService();
|
||||
StreamSubscription? _volumeUpSub;
|
||||
StreamSubscription? _volumeDownSub;
|
||||
@@ -137,9 +164,91 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_musicProvider = MusicAssistantProvider();
|
||||
_themeProvider = ThemeProvider();
|
||||
_localeProvider = LocaleProvider();
|
||||
_navigationProvider = NavigationProvider();
|
||||
_sleepTimerProvider = SleepTimerProvider();
|
||||
|
||||
// Initialize connection provider first (others depend on it)
|
||||
_connectionProvider = ConnectionProvider();
|
||||
_uiStateProvider = UIStateProvider();
|
||||
|
||||
// Initialize shared image helper service
|
||||
_imageHelper = ImageHelperService(
|
||||
api: _connectionProvider.api,
|
||||
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
|
||||
);
|
||||
|
||||
// Initialize library provider (needs API from connection)
|
||||
_libraryProvider = LibraryProvider(
|
||||
api: _connectionProvider.api,
|
||||
logger: DebugLogger(),
|
||||
cacheService: _cacheService,
|
||||
settings: null, // Will use static methods
|
||||
providerFilterService: ProviderFilterService(
|
||||
providerFilter: _connectionProvider.providerFilter,
|
||||
playerFilter: _connectionProvider.playerFilter,
|
||||
availableMusicProviders: [],
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [],
|
||||
podcasts: [],
|
||||
radioStations: [],
|
||||
),
|
||||
);
|
||||
|
||||
// Initialize local player provider
|
||||
_localPlayerProvider = LocalPlayerProvider(
|
||||
audioHandler: audioHandler,
|
||||
settings: null, // Will use static methods
|
||||
);
|
||||
|
||||
// Initialize player provider (depends on several others)
|
||||
// Note: queueManager will be set later to avoid circular dependency
|
||||
_playerProvider = PlayerProvider(
|
||||
api: _connectionProvider.api,
|
||||
logger: DebugLogger(),
|
||||
cacheService: _cacheService,
|
||||
settings: null, // Will use static methods
|
||||
positionTracker: _positionTracker,
|
||||
queueManager: null, // Will be set after initialization
|
||||
sleepTimerProvider: _sleepTimerProvider,
|
||||
imageHelper: _imageHelper,
|
||||
playerSyncState: PlayerSyncState(
|
||||
availablePlayers: [],
|
||||
castToSendspinIdMap: {},
|
||||
),
|
||||
audioHandler: audioHandler,
|
||||
);
|
||||
|
||||
// Create queue manager with functions that access provider state
|
||||
final queueManager = QueueManagerService(
|
||||
api: _connectionProvider.api,
|
||||
database: DatabaseService.instance,
|
||||
logger: DebugLogger(),
|
||||
getAvailablePlayers: () => _playerProvider.availablePlayers,
|
||||
getCastToSendspinMap: () => _playerProvider.castToSendspinIdMap,
|
||||
playTracks: (playerId, tracks, {int? startIndex, bool? clearQueue}) async {
|
||||
await _connectionProvider.api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue ?? true);
|
||||
},
|
||||
);
|
||||
|
||||
// Set the queue manager after creation (breaks circular dependency)
|
||||
_playerProvider.queueManager = queueManager;
|
||||
|
||||
// Initialize main provider (will act as facade)
|
||||
// For now, don't pass providers - MusicAssistantProvider doesn't accept them yet
|
||||
// It will delegate to the new providers gradually
|
||||
_musicProvider = MusicAssistantProvider();
|
||||
|
||||
// Wire up player provider callbacks
|
||||
_playerProvider.onPlayerChanged = () {
|
||||
setState(() {}); // Trigger rebuild on player change
|
||||
};
|
||||
_playerProvider.onTrackChanged = () {
|
||||
setState(() {}); // Trigger rebuild on track change
|
||||
};
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initHardwareVolumeControl();
|
||||
// Listen to player selection changes to toggle volume interception
|
||||
@@ -252,9 +361,21 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// Core facade provider (will be removed after migration)
|
||||
ChangeNotifierProvider.value(value: _musicProvider),
|
||||
|
||||
// Theme providers
|
||||
ChangeNotifierProvider.value(value: _themeProvider),
|
||||
ChangeNotifierProvider.value(value: _localeProvider),
|
||||
ChangeNotifierProvider.value(value: _navigationProvider),
|
||||
|
||||
// New multi-provider architecture (available for gradual migration)
|
||||
ChangeNotifierProvider.value(value: _connectionProvider),
|
||||
ChangeNotifierProvider.value(value: _uiStateProvider),
|
||||
ChangeNotifierProvider.value(value: _libraryProvider),
|
||||
ChangeNotifierProvider.value(value: _playerProvider),
|
||||
ChangeNotifierProvider.value(value: _localPlayerProvider),
|
||||
ChangeNotifierProvider.value(value: _sleepTimerProvider),
|
||||
],
|
||||
child: Consumer2<ThemeProvider, LocaleProvider>(
|
||||
builder: (context, themeProvider, localeProvider, _) {
|
||||
@@ -450,94 +571,120 @@ class _AppStartupState extends State<AppStartup> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettingsFirst();
|
||||
try {
|
||||
_loadSettingsFirst();
|
||||
} catch (e) {
|
||||
_logger.error('AppStartup init error: $e');
|
||||
// Fallback - mark as loaded so app can continue
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
_hasCompletedOnboarding = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load settings BEFORE checking connection.
|
||||
/// This ensures we know if this is a first-time user before transitioning.
|
||||
Future<void> _loadSettingsFirst() async {
|
||||
// Load onboarding state first - this is critical for determining
|
||||
// when to transition to HomeScreen
|
||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
try {
|
||||
// Load onboarding state first - this is critical for determining
|
||||
// when to transition to HomeScreen
|
||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
|
||||
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
|
||||
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
|
||||
} catch (e) {
|
||||
_logger.error('AppStartup settings load error: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
_hasCompletedOnboarding = true; // Default to true on error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Now check connection
|
||||
_checkAndConnect();
|
||||
}
|
||||
|
||||
Future<void> _checkAndConnect() async {
|
||||
final serverUrl = await SettingsService.getServerUrl();
|
||||
try {
|
||||
final serverUrl = await SettingsService.getServerUrl();
|
||||
|
||||
if (!mounted) return;
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_savedServerUrl = serverUrl;
|
||||
});
|
||||
|
||||
// If we have a saved server URL, attempt auto-connection
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_savedServerUrl = serverUrl;
|
||||
});
|
||||
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
// If we have a saved server URL, attempt auto-connection
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
});
|
||||
|
||||
// Connection is handled by MusicAssistantProvider._initialize()
|
||||
// Just wait for it to complete or timeout
|
||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// For first-time users, we need to wait for both connection AND player selection
|
||||
// so the welcome overlay can appear immediately without home screen flash.
|
||||
// For returning users, just wait for connection.
|
||||
final needsWelcome = !_hasCompletedOnboarding;
|
||||
// Connection is handled by MusicAssistantProvider._initialize()
|
||||
// Just wait for it to complete or timeout
|
||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||
|
||||
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||
// Check connection state periodically
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
// For first-time users, we need to wait for both connection AND player selection
|
||||
// so the welcome overlay can appear immediately without home screen flash.
|
||||
// For returning users, just wait for connection.
|
||||
final needsWelcome = !_hasCompletedOnboarding;
|
||||
|
||||
if (needsWelcome) {
|
||||
// First-time user: wait for connection + player selection
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
||||
break;
|
||||
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||
// Check connection state periodically
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
|
||||
if (needsWelcome) {
|
||||
// First-time user: wait for connection + player selection
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Returning user: just wait for connection
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Returning user: just wait for connection
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
|
||||
if (provider.connectionState == MAConnectionState.error) {
|
||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.connectionState == MAConnectionState.error) {
|
||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||
break;
|
||||
if (!provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
_logger.error('AppStartup connection error: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
_connectionAttempted = true;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_connectionAttempted = true;
|
||||
});
|
||||
}
|
||||
_connectionAttempted = true;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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/sync_service.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/position_tracker.dart';
|
||||
import '../services/sendspin_service.dart';
|
||||
import '../services/pcm_audio_player.dart';
|
||||
@@ -28,6 +27,14 @@ import '../services/offline_action_queue.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../services/database_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;
|
||||
|
||||
/// Main provider that coordinates connection, player, and library state.
|
||||
@@ -85,7 +92,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Local player state
|
||||
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)
|
||||
|
||||
// Home refresh counter - increments to signal home screen to refresh all rows
|
||||
@@ -111,6 +118,44 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// PCM audio player for raw Sendspin audio streaming
|
||||
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
|
||||
String _lastSearchQuery = '';
|
||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||
@@ -119,10 +164,6 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
'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
|
||||
// Empty list means all providers are allowed
|
||||
List<String> _providerFilter = [];
|
||||
@@ -168,7 +209,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
|
||||
/// 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
|
||||
/// Returns true if:
|
||||
@@ -176,58 +217,33 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// - 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;
|
||||
return _providerFilterService.isItemAllowedByProviderFilter(item);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
return _providerFilterService.filterByProvider(items);
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
};
|
||||
return _providerFilterService.filterSearchResults(results);
|
||||
}
|
||||
|
||||
/// Player filter from MA user settings (empty = all players allowed)
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
|
||||
/// 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
|
||||
bool isPlayerAllowedByFilter(Player player) {
|
||||
if (_playerFilter.isEmpty) return true;
|
||||
return _playerFilter.contains(player.playerId);
|
||||
return _providerFilterService.isPlayerAllowedByFilter(player);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
return _providerFilterService.filterPlayers(players);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -244,12 +260,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// 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);
|
||||
return _imageHelper?.getProviderIconSvg(domain, isDark: isDark);
|
||||
}
|
||||
|
||||
/// Get provider manifest by domain
|
||||
ProviderManifest? getProviderManifest(String domain) {
|
||||
return _api?.getProviderManifest(domain);
|
||||
return _imageHelper?.getProviderManifest(domain);
|
||||
}
|
||||
|
||||
/// List of enabled provider instance IDs (empty = all enabled)
|
||||
@@ -500,67 +516,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// Raw unfiltered players list (for internal use)
|
||||
List<Player> get availablePlayersUnfiltered => _availablePlayers;
|
||||
|
||||
/// 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')
|
||||
/// 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;
|
||||
return _playerSyncState.isPlayerManuallySynced(playerId);
|
||||
}
|
||||
|
||||
Track? get currentTrack => _currentTrack;
|
||||
@@ -777,9 +738,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
/// 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);
|
||||
return _imageHelper?.getCachedArtworkUrl(playerId, size: size);
|
||||
}
|
||||
|
||||
/// Clear cached track for a player (e.g., after queue transfer)
|
||||
@@ -940,8 +899,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Future<void> _loadPodcastCoverCache() async {
|
||||
try {
|
||||
final cache = await SettingsService.getPodcastCoverCache();
|
||||
if (cache.isNotEmpty) {
|
||||
_podcastCoverCache.addAll(cache);
|
||||
if (cache.isNotEmpty && _imageHelper != null) {
|
||||
await _imageHelper!.loadPodcastCoverCache(cache);
|
||||
_logger.log('📦 Loaded ${cache.length} podcast covers from cache (instant high-res)');
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1047,6 +1006,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||
|
||||
// Initialize image helper service with API reference
|
||||
_imageHelper = ImageHelperService(
|
||||
api: _api,
|
||||
getCachedTrackForPlayer: (playerId) => _cacheService.getCachedTrackForPlayer(playerId),
|
||||
);
|
||||
|
||||
_connectionStateSubscription?.cancel();
|
||||
_connectionStateSubscription = _api!.connectionState.listen(
|
||||
(state) async {
|
||||
@@ -2394,7 +2359,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
void _handleSendspinVolume(int volumeLevel) async {
|
||||
_logger.log('🔊 Sendspin: Set volume to $volumeLevel');
|
||||
_localPlayerVolume = volumeLevel;
|
||||
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
|
||||
await FlutterVolumeController.setVolume(volumeLevel / VolumeConstants.range.toDouble());
|
||||
_sendspinService?.reportState(volume: volumeLevel);
|
||||
}
|
||||
|
||||
@@ -2652,7 +2617,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final volume = event['volume_level'] as int? ?? event['volume'] as int?;
|
||||
if (volume != null) {
|
||||
_localPlayerVolume = volume;
|
||||
await FlutterVolumeController.setVolume(volume / 100.0);
|
||||
await FlutterVolumeController.setVolume(volume / VolumeConstants.range.toDouble());
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -3667,44 +3632,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// ============================================================================
|
||||
|
||||
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false, bool libraryOnly = false}) async {
|
||||
final baseKey = query.toLowerCase().trim();
|
||||
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': []};
|
||||
}
|
||||
return _searchService.searchWithCache(query, forceRefresh: forceRefresh, libraryOnly: libraryOnly);
|
||||
}
|
||||
|
||||
void clearAllDetailCaches() {
|
||||
_cacheService.clearAllDetailCaches();
|
||||
_searchService.clearAllDetailCaches();
|
||||
}
|
||||
|
||||
/// Get cached album tracks (for instant display before background refresh)
|
||||
@@ -3737,32 +3669,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// Sort players list based on smart sort setting
|
||||
/// Can be called synchronously with pre-fetched settings for cached players
|
||||
void _sortPlayersSync(List<Player> players, 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
|
||||
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()));
|
||||
}
|
||||
PlayerSortUtility.sortPlayers(
|
||||
players,
|
||||
smartSort: smartSort,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
||||
@@ -4126,7 +4037,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Update notification for builtin player using local mode method (keeps pause working)
|
||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
: player.name;
|
||||
@@ -4165,7 +4076,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// and the player is playing (don't wait for polling to kick in)
|
||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||
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"
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
@@ -4348,7 +4259,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${_selectedPlayer!.name}'
|
||||
: _selectedPlayer!.name;
|
||||
@@ -4454,7 +4365,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
_precacheImage(imageUrl);
|
||||
}
|
||||
@@ -4803,7 +4714,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Update notification for ALL players
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
final artworkUrl = _api?.getImageUrl(track, size: ImageSizes.highRes);
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isBuiltinPlayer = builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId;
|
||||
|
||||
@@ -4906,7 +4817,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
title: track.name,
|
||||
artist: track.artistsString,
|
||||
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,
|
||||
);
|
||||
audioHandler.updateLocalModeNotification(
|
||||
@@ -5105,18 +5016,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// Load high-resolution podcast covers from iTunes in background
|
||||
/// iTunes provides 800x800 artwork for most podcasts (reduced from 1400 for efficiency)
|
||||
Future<void> _loadPodcastCoversInBackground() async {
|
||||
if (_api == null) return;
|
||||
if (_api == null || _imageHelper == null) return;
|
||||
|
||||
for (final podcast in _podcasts) {
|
||||
try {
|
||||
// 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
|
||||
final itunesArtwork = await _api!.getITunesPodcastArtwork(podcast.name);
|
||||
|
||||
if (itunesArtwork != null) {
|
||||
_podcastCoverCache[podcast.itemId] = itunesArtwork;
|
||||
_imageHelper!.updatePodcastCoverCache(podcast.itemId, itunesArtwork);
|
||||
_logger.log('🎙️ Cached iTunes artwork for ${podcast.name}');
|
||||
|
||||
// Persist to storage for instant display on next launch
|
||||
@@ -5250,33 +5161,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
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
|
||||
/// 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);
|
||||
return _imageHelper?.getPodcastImageUrl(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);
|
||||
return _imageHelper?.getArtistImageUrlWithFallback(artist, size: size);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -5288,104 +5186,17 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||
// If this player is a group child, fetch the leader's queue instead
|
||||
// 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;
|
||||
return _queueManager.getQueue(playerId);
|
||||
}
|
||||
|
||||
/// Get cached queue for instant display (before API refresh)
|
||||
Future<PlayerQueue?> getCachedQueue(String playerId) async {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
return _queueManager.getCachedQueue(playerId);
|
||||
}
|
||||
|
||||
/// Persist queue to database for app restart persistence
|
||||
void _persistQueueToDatabase(String playerId, PlayerQueue queue) {
|
||||
() async {
|
||||
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');
|
||||
}
|
||||
}();
|
||||
_queueManager.persistQueueToDatabase(playerId, queue);
|
||||
}
|
||||
|
||||
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||
@@ -5535,42 +5346,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
/// 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 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;
|
||||
}
|
||||
return _queueManager.restoreQueueFromCache(playerId);
|
||||
}
|
||||
|
||||
Future<void> nextTrack(String playerId) async {
|
||||
@@ -5955,7 +5731,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId != null && playerId == builtinPlayerId) {
|
||||
_localPlayerVolume = volumeLevel;
|
||||
await FlutterVolumeController.setVolume(volumeLevel / 100.0);
|
||||
await FlutterVolumeController.setVolume(volumeLevel / VolumeConstants.range.toDouble());
|
||||
}
|
||||
await _api?.setVolume(playerId, volumeLevel);
|
||||
} catch (e) {
|
||||
@@ -6118,21 +5894,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
|
||||
String nextMode;
|
||||
switch (currentMode) {
|
||||
case 'off':
|
||||
case null:
|
||||
nextMode = 'all';
|
||||
break;
|
||||
case 'all':
|
||||
nextMode = 'one';
|
||||
break;
|
||||
case 'one':
|
||||
nextMode = 'off';
|
||||
break;
|
||||
default:
|
||||
nextMode = 'off';
|
||||
}
|
||||
final nextMode = QueueControls.getNextRepeatMode(currentMode);
|
||||
await setRepeatMode(queueId, nextMode);
|
||||
}
|
||||
|
||||
@@ -6162,19 +5924,5 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a hostname is a local/private network address
|
||||
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
|
||||
}
|
||||
bool _isLocalNetworkHost(String host) => NetworkUtils.isLocalNetworkHost(host);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:audio_service/audio_service.dart' as audio_service;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../providers/sleep_timer_provider.dart';
|
||||
import '../services/audio/massiv_audio_handler.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/image_helper_service.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/position_tracker.dart';
|
||||
import '../services/queue_manager_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/player_sort_utility.dart';
|
||||
import '../utils/player_sync_state.dart';
|
||||
|
||||
/// Provider for player management and playback controls.
|
||||
///
|
||||
/// Manages:
|
||||
/// - Player selection and available players list
|
||||
/// - Current playback state and track
|
||||
/// - Play/pause/stop/next/previous/seek controls
|
||||
/// - Queue operations
|
||||
/// - Position tracking
|
||||
/// - Sleep timer integration
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to isolate player logic.
|
||||
class PlayerProvider extends ChangeNotifier {
|
||||
final MusicAssistantAPI? _api;
|
||||
final DebugLogger _logger;
|
||||
final CacheService _cacheService;
|
||||
final SettingsService? _settings;
|
||||
final PositionTracker _positionTracker;
|
||||
QueueManagerService? _queueManager;
|
||||
final SleepTimerProvider _sleepTimerProvider;
|
||||
final ImageHelperService _imageHelper;
|
||||
final PlayerSyncState _playerSyncState;
|
||||
|
||||
// Player state
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
Map<String, String> _castToSendspinIdMap = {};
|
||||
bool _selectPlayerInProgress = false;
|
||||
|
||||
// Current playback
|
||||
Track? _currentTrack;
|
||||
Audiobook? _currentAudiobook;
|
||||
String? _currentPodcastName;
|
||||
TrackMetadata? _pendingTrackMetadata;
|
||||
TrackMetadata? _currentNotificationMetadata;
|
||||
|
||||
// Timers
|
||||
Timer? _playerStateTimer;
|
||||
Timer? _notificationPositionTimer;
|
||||
Timer? _idleServiceTimer;
|
||||
|
||||
// Audio handler (MassivAudioHandler for custom notification methods)
|
||||
final MassivAudioHandler _audioHandler;
|
||||
|
||||
// Callbacks for coordination
|
||||
Function()? onPlayerChanged;
|
||||
Function()? onTrackChanged;
|
||||
Function()? onAudiobookChanged;
|
||||
|
||||
PlayerProvider({
|
||||
required MusicAssistantAPI? api,
|
||||
required DebugLogger logger,
|
||||
required CacheService cacheService,
|
||||
SettingsService? settings,
|
||||
required PositionTracker positionTracker,
|
||||
QueueManagerService? queueManager,
|
||||
required SleepTimerProvider sleepTimerProvider,
|
||||
required ImageHelperService imageHelper,
|
||||
required PlayerSyncState playerSyncState,
|
||||
required MassivAudioHandler audioHandler,
|
||||
}) : _api = api,
|
||||
_logger = logger,
|
||||
_cacheService = cacheService,
|
||||
_settings = settings,
|
||||
_positionTracker = positionTracker,
|
||||
_queueManager = queueManager,
|
||||
_sleepTimerProvider = sleepTimerProvider,
|
||||
_imageHelper = imageHelper,
|
||||
_playerSyncState = playerSyncState,
|
||||
_audioHandler = audioHandler {
|
||||
// Connect position tracker to sleep timer
|
||||
_positionTracker.positionStream.listen((position) {
|
||||
// Notify listeners for position updates
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
// Set up sleep timer expiration callback
|
||||
_sleepTimerProvider.onExpired = () {
|
||||
pausePlayer(_selectedPlayer?.playerId ?? '');
|
||||
};
|
||||
}
|
||||
|
||||
// Getters
|
||||
Player? get selectedPlayer => _selectedPlayer;
|
||||
List<Player> get availablePlayers => _availablePlayers;
|
||||
Track? get currentTrack => _currentTrack;
|
||||
Audiobook? get currentAudiobook => _currentAudiobook;
|
||||
String? get currentPodcastName => _currentPodcastName;
|
||||
bool get selectPlayerInProgress => _selectPlayerInProgress;
|
||||
Map<String, String> get castToSendspinIdMap => _castToSendspinIdMap;
|
||||
|
||||
/// Set the queue manager (can be set after initialization to avoid circular deps)
|
||||
set queueManager(QueueManagerService? manager) {
|
||||
_queueManager = manager;
|
||||
}
|
||||
|
||||
bool get isPlaying => _selectedPlayer?.state == 'playing';
|
||||
bool get isPaused => _selectedPlayer?.state == 'paused';
|
||||
bool get isStopped => _selectedPlayer?.state == 'idle' ||
|
||||
_selectedPlayer?.state == 'off' ||
|
||||
_selectedPlayer == null;
|
||||
|
||||
String? get currentArtworkUrl {
|
||||
if (_currentTrack != null) {
|
||||
return _imageHelper.getImageUrl(_currentTrack!, size: ImageSizes.highRes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER LOADING AND SELECTION
|
||||
// ============================================================================
|
||||
|
||||
/// Load players and auto-select the best one
|
||||
Future<void> loadAndSelectPlayers({bool forceRefresh = false, bool coldStart = false}) async {
|
||||
try {
|
||||
// Check cache validity
|
||||
if (!forceRefresh &&
|
||||
!coldStart &&
|
||||
_cacheService.isPlayersCacheValid() &&
|
||||
_availablePlayers.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Cast-to-Sendspin mappings
|
||||
await _loadCastToSendspinMappings();
|
||||
|
||||
final allPlayers = await getPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
||||
|
||||
// Filter players
|
||||
_availablePlayers = _filterPlayers(allPlayers, builtinPlayerId);
|
||||
|
||||
// Handle Cast/Sendspin player switching
|
||||
await _handleSendspinPlayers();
|
||||
|
||||
// Sort players
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
_sortPlayersSync(smartSort, builtinPlayerId);
|
||||
|
||||
// Auto-select player if none selected or on cold start
|
||||
if (_selectedPlayer == null || coldStart) {
|
||||
final playerToSelect = _selectBestPlayer(builtinPlayerId);
|
||||
if (playerToSelect != null) {
|
||||
await selectPlayer(playerToSelect);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error loading players: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all players from API
|
||||
Future<List<Player>> getPlayers() async {
|
||||
return await _api?.getPlayers() ?? [];
|
||||
}
|
||||
|
||||
/// Select a player as the active player
|
||||
Future<void> selectPlayer(Player player, {bool skipNotify = false}) async {
|
||||
if (_selectPlayerInProgress) {
|
||||
_logger.log('⚠️ selectPlayer already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
_selectPlayerInProgress = true;
|
||||
|
||||
try {
|
||||
_selectedPlayer = player;
|
||||
|
||||
// Cache selection
|
||||
_cacheService.setCachedSelectedPlayer(player);
|
||||
await SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||
|
||||
// Set current track from cache for instant display
|
||||
_currentTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
|
||||
// Initialize position tracker
|
||||
_positionTracker.onPlayerSelected(player.playerId);
|
||||
if (!player.isExternalSource) {
|
||||
_positionTracker.updateFromServer(
|
||||
playerId: player.playerId,
|
||||
position: player.elapsedTime ?? 0.0,
|
||||
isPlaying: player.state == 'playing',
|
||||
duration: _currentTrack?.duration,
|
||||
serverTimestamp: player.elapsedTimeLastUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
// Update audio handler notification
|
||||
await _updateNotificationForPlayer(player);
|
||||
|
||||
// Start player state polling
|
||||
_startPlayerStatePolling();
|
||||
_manageNotificationPositionTimer();
|
||||
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
onPlayerChanged?.call();
|
||||
}
|
||||
} finally {
|
||||
_selectPlayerInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update audio handler notification for the selected player
|
||||
Future<void> _updateNotificationForPlayer(Player player) async {
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isBuiltinPlayer = builtinPlayerId != null && player.playerId == builtinPlayerId;
|
||||
|
||||
if (isBuiltinPlayer) {
|
||||
_audioHandler.setLocalMode();
|
||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _imageHelper.getImageUrl(track, size: ImageSizes.highRes);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
: player.name;
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: artistWithPlayer,
|
||||
album: track.album?.name ?? '',
|
||||
duration: track.duration,
|
||||
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
);
|
||||
_audioHandler.updateLocalModeNotification(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
duration: track.duration,
|
||||
);
|
||||
} else if (player.state == 'playing' || player.state == 'paused') {
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: 'player_${player.playerId}',
|
||||
title: player.name,
|
||||
artist: 'Loading...',
|
||||
);
|
||||
_audioHandler.updateLocalModeNotification(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
);
|
||||
} else {
|
||||
_audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
}
|
||||
} else {
|
||||
// Remote player
|
||||
if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _imageHelper.getImageUrl(track, size: ImageSizes.highRes);
|
||||
final artistWithPlayer = track.artistsString.isNotEmpty
|
||||
? '${track.artistsString} • ${player.name}'
|
||||
: player.name;
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: artistWithPlayer,
|
||||
album: track.album?.name ?? '',
|
||||
duration: track.duration,
|
||||
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null,
|
||||
);
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
_audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
position: position,
|
||||
duration: track.duration,
|
||||
);
|
||||
} else if (player.state == 'playing' || player.state == 'paused') {
|
||||
final mediaItem = audio_service.MediaItem(
|
||||
id: 'player_${player.playerId}',
|
||||
title: player.name,
|
||||
artist: 'Loading...',
|
||||
);
|
||||
final position = _positionTracker.currentPosition;
|
||||
_cancelIdleServiceTimer();
|
||||
_audioHandler.setRemotePlaybackState(
|
||||
item: mediaItem,
|
||||
playing: player.state == 'playing',
|
||||
position: position,
|
||||
duration: Duration.zero,
|
||||
);
|
||||
} else {
|
||||
_audioHandler.clearRemotePlaybackState();
|
||||
_startIdleServiceTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYBACK CONTROLS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> playPauseSelectedPlayer() async {
|
||||
if (_selectedPlayer == null) return;
|
||||
final playerId = _selectedPlayer!.playerId;
|
||||
if (_selectedPlayer?.state == 'playing') {
|
||||
await pausePlayer(playerId);
|
||||
} else {
|
||||
await playPlayer(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playPlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.resumePlayer(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to play: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pausePlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.pausePlayer(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to pause: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopPlayer(String playerId) async {
|
||||
try {
|
||||
await _api?.stopPlayer(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to stop: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> nextTrack(String playerId) async {
|
||||
try {
|
||||
await _api?.nextTrack(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to skip to next: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> previousTrack(String playerId) async {
|
||||
try {
|
||||
await _api?.previousTrack(playerId);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to go to previous: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(String playerId, int positionSeconds) async {
|
||||
try {
|
||||
await _api?.seek(playerId, positionSeconds);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to seek: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seekRelative(String playerId, int deltaSeconds) async {
|
||||
final currentPosition = _positionTracker.currentPosition.inSeconds;
|
||||
await seek(playerId, (currentPosition + deltaSeconds).clamp(0, double.infinity).toInt());
|
||||
}
|
||||
|
||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||
try {
|
||||
await _api?.setVolume(playerId, volumeLevel);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to set volume: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TRACK PLAYBACK
|
||||
// ============================================================================
|
||||
|
||||
Future<void> playTrack(String playerId, Track track, {bool clearQueue = true}) async {
|
||||
try {
|
||||
await _api?.playTrack(playerId, track, clearQueue: clearQueue);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to play track: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||
try {
|
||||
await _api?.playTracks(playerId, tracks, startIndex: startIndex, clearQueue: clearQueue);
|
||||
|
||||
if (tracks.isNotEmpty && startIndex != null && startIndex < tracks.length) {
|
||||
_currentTrack = tracks[startIndex];
|
||||
} else if (tracks.isNotEmpty) {
|
||||
_currentTrack = tracks.first;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
onTrackChanged?.call();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to play tracks: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
Future<PlayerQueue?> getQueue(String playerId) async {
|
||||
return _queueManager?.getQueue(playerId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POLLING AND STATE UPDATES
|
||||
// ============================================================================
|
||||
|
||||
void _startPlayerStatePolling() {
|
||||
_playerStateTimer?.cancel();
|
||||
_playerStateTimer = Timer.periodic(
|
||||
Timings.playerPollingInterval,
|
||||
(_) => _updatePlayerState(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updatePlayerState() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
try {
|
||||
final playerId = _selectedPlayer!.playerId;
|
||||
|
||||
// Get all players and find our selected one
|
||||
final allPlayers = await _api?.getPlayers();
|
||||
final updatedPlayer = allPlayers?.firstWhere(
|
||||
(p) => p.playerId == playerId,
|
||||
orElse: () => _selectedPlayer!,
|
||||
);
|
||||
|
||||
if (updatedPlayer == null) {
|
||||
_logger.log('⚠️ _updatePlayerState: Player not found: $playerId');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player changed during fetch
|
||||
if (_selectedPlayer?.playerId != playerId) {
|
||||
_logger.log('⚠️ _updatePlayerState: Player changed during fetch, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
final previousState = _selectedPlayer!.state;
|
||||
_selectedPlayer = updatedPlayer;
|
||||
|
||||
// Update position tracker
|
||||
_positionTracker.updateFromServer(
|
||||
playerId: playerId,
|
||||
position: updatedPlayer.elapsedTime ?? 0.0,
|
||||
isPlaying: updatedPlayer.state == 'playing',
|
||||
duration: _currentTrack?.duration,
|
||||
serverTimestamp: updatedPlayer.elapsedTimeLastUpdated,
|
||||
);
|
||||
|
||||
// Update notification if state changed
|
||||
if (previousState != updatedPlayer.state) {
|
||||
await _updateNotificationForPlayer(updatedPlayer);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error updating player state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _manageNotificationPositionTimer() {
|
||||
_notificationPositionTimer?.cancel();
|
||||
|
||||
// Only for remote/Sendspin players
|
||||
// Note: We don't have a sync version, so we use a cached value or null
|
||||
final builtinPlayerId = _selectedPlayer?.playerId; // Simplified for now
|
||||
final isRemotePlayer = _selectedPlayer != null &&
|
||||
(builtinPlayerId == null || _selectedPlayer!.playerId != builtinPlayerId);
|
||||
|
||||
if (isRemotePlayer && (_selectedPlayer?.state == 'playing' || _selectedPlayer?.state == 'paused')) {
|
||||
_notificationPositionTimer = Timer.periodic(
|
||||
Timings.notificationPositionUpdate,
|
||||
(_) => _updateNotificationPosition(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateNotificationPosition() {
|
||||
if (_currentTrack == null || _selectedPlayer == null) return;
|
||||
|
||||
final position = _positionTracker.currentPosition;
|
||||
// Update notification position
|
||||
// (This would be handled by audio_handler internally)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
Future<void> setSleepTimer(int? minutes) async {
|
||||
_sleepTimerProvider.setTimer(minutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cancelSleepTimer() async {
|
||||
_sleepTimerProvider.setTimer(null);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER METHODS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> _loadCastToSendspinMappings() async {
|
||||
if (_castToSendspinIdMap.isEmpty) {
|
||||
try {
|
||||
final persistedMappings = await DatabaseService.instance.getAllCastToSendspinMappings();
|
||||
_castToSendspinIdMap.addAll(persistedMappings);
|
||||
if (persistedMappings.isNotEmpty) {
|
||||
_logger.log('🔗 Loaded ${persistedMappings.length} Cast->Sendspin mappings from database');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to load Cast->Sendspin mappings: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Player> _filterPlayers(List<Player> allPlayers, String? builtinPlayerId) {
|
||||
return PlayerSortUtility.filterPlayers(
|
||||
allPlayers,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSendspinPlayers() async {
|
||||
// Complex Sendspin/Cast player switching logic
|
||||
// This would need to be extracted fully
|
||||
// For now, we'll keep a simplified version
|
||||
}
|
||||
|
||||
void _sortPlayersSync(bool smartSort, String? builtinPlayerId) {
|
||||
PlayerSortUtility.sortPlayers(
|
||||
_availablePlayers,
|
||||
smartSort: smartSort,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
Player? _selectBestPlayer(String? builtinPlayerId) {
|
||||
return PlayerSortUtility.selectBestPlayer(
|
||||
availablePlayers: _availablePlayers,
|
||||
lastSelectedPlayer: null, // Could load from settings
|
||||
lastActivePlayer: null,
|
||||
builtinPlayerId: builtinPlayerId,
|
||||
);
|
||||
}
|
||||
|
||||
void _startIdleServiceTimer() {
|
||||
_idleServiceTimer?.cancel();
|
||||
_idleServiceTimer = Timer(const Duration(minutes: 30), () {
|
||||
// After 30 min idle, stop position tracking
|
||||
_positionTracker.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelIdleServiceTimer() {
|
||||
_idleServiceTimer?.cancel();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLEANUP
|
||||
// ============================================================================
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playerStateTimer?.cancel();
|
||||
_notificationPositionTimer?.cancel();
|
||||
_idleServiceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class SleepTimerProvider with ChangeNotifier {
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||
Timer? _sleepTimerDisplayTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Callback to pause playback when timer expires
|
||||
OnSleepTimerExpired? onExpired;
|
||||
@@ -74,7 +75,9 @@ class SleepTimerProvider with ChangeNotifier {
|
||||
|
||||
// Start display update timer (every second for countdown)
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
if (!_disposed) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
}
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
@@ -123,6 +126,7 @@ class SleepTimerProvider with ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_cancelInternal();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../models/provider_instance.dart';
|
||||
|
||||
/// Provider for UI-only state that doesn't belong to any specific domain.
|
||||
///
|
||||
/// Manages:
|
||||
/// - Search state (query and results)
|
||||
/// - Provider and player filters (from MA user settings)
|
||||
/// - User-controlled provider enable/disable
|
||||
/// - Loading states
|
||||
/// - Home refresh counter
|
||||
///
|
||||
/// This state is UI-focused and doesn't involve business logic operations.
|
||||
class UIStateProvider extends ChangeNotifier {
|
||||
// Search state
|
||||
String _lastSearchQuery = '';
|
||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
|
||||
// Provider filter from MA user settings (empty = all providers allowed)
|
||||
List<String> _providerFilter = [];
|
||||
|
||||
// Player filter from MA user settings (empty = all players allowed)
|
||||
List<String> _playerFilter = [];
|
||||
|
||||
// User-controlled music provider filter (local settings in Ensemble)
|
||||
// Empty list means all providers are enabled (no filtering)
|
||||
List<String> _enabledProviderIds = [];
|
||||
|
||||
// Available music providers discovered from MA
|
||||
List<ProviderInstance> _availableMusicProviders = [];
|
||||
|
||||
// Loading states
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingRadio = false;
|
||||
bool _isLoadingPodcasts = false;
|
||||
|
||||
// Home refresh counter - increments to signal home screen to refresh all rows
|
||||
int _homeRefreshCounter = 0;
|
||||
|
||||
// Getters
|
||||
String get lastSearchQuery => _lastSearchQuery;
|
||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
List<String> get enabledProviderIds => _enabledProviderIds;
|
||||
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingRadio => _isLoadingRadio;
|
||||
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
||||
int get homeRefreshCounter => _homeRefreshCounter;
|
||||
|
||||
/// Whether provider filtering is active
|
||||
bool get hasProviderFilter => _providerFilter.isNotEmpty;
|
||||
|
||||
/// Whether player filtering is active
|
||||
bool get hasPlayerFilter => _playerFilter.isNotEmpty;
|
||||
|
||||
/// Whether user provider filter is active
|
||||
bool get hasEnabledProviderFilter => _enabledProviderIds.isNotEmpty;
|
||||
|
||||
// Search state management
|
||||
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||
_lastSearchQuery = query;
|
||||
_lastSearchResults = results;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearSearchState() {
|
||||
_lastSearchQuery = '';
|
||||
_lastSearchResults = {'artists': [], 'albums': [], 'tracks': []};
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Filter management
|
||||
void setProviderFilter(List<String> filter) {
|
||||
_providerFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPlayerFilter(List<String> filter) {
|
||||
_playerFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setAvailableMusicProviders(List<ProviderInstance> providers) {
|
||||
_availableMusicProviders = providers;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setEnabledProviderIds(List<String> enabledIds) {
|
||||
_enabledProviderIds = enabledIds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Toggle a specific music provider on/off
|
||||
void toggleProviderEnabled(String instanceId, bool enabled) {
|
||||
final allIds = _availableMusicProviders.map((p) => p.instanceId).toList();
|
||||
|
||||
// Don't allow disabling the last provider
|
||||
if (!enabled && _enabledProviderIds.isNotEmpty && _enabledProviderIds.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state
|
||||
if (_enabledProviderIds.isEmpty && !enabled) {
|
||||
// Switch to explicit mode
|
||||
_enabledProviderIds = allIds.where((id) => id != instanceId).toList();
|
||||
} else if (enabled) {
|
||||
if (!_enabledProviderIds.contains(instanceId)) {
|
||||
_enabledProviderIds = [..._enabledProviderIds, instanceId];
|
||||
}
|
||||
} else {
|
||||
_enabledProviderIds = _enabledProviderIds.where((id) => id != instanceId).toList();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if a provider should be visible based on both filters
|
||||
bool isProviderEnabled(String instanceId) {
|
||||
// Check MA user filter (server-side restriction)
|
||||
if (hasProviderFilter && !_providerFilter.contains(instanceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check user filter (local preference)
|
||||
if (hasEnabledProviderFilter && !_enabledProviderIds.contains(instanceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Check if a player should be visible based on MA user filter
|
||||
bool isPlayerAllowed(String playerId) {
|
||||
if (!hasPlayerFilter) return true;
|
||||
return _playerFilter.contains(playerId);
|
||||
}
|
||||
|
||||
// Loading state management
|
||||
void setLoading(bool loading) {
|
||||
_isLoading = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setLoadingRadio(bool loading) {
|
||||
_isLoadingRadio = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setLoadingPodcasts(bool loading) {
|
||||
_isLoadingPodcasts = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void incrementHomeRefreshCounter() {
|
||||
_homeRefreshCounter++;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1298,12 +1298,13 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
ListTile(
|
||||
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
|
||||
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
// Add to queue on selected player
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player != null) {
|
||||
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
|
||||
try {
|
||||
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -1312,7 +1313,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_showError(S.of(context)!.noPlayerSelected);
|
||||
}
|
||||
|
||||
@@ -364,7 +364,6 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly),
|
||||
child: FloatingActionButton(
|
||||
onPressed: () {
|
||||
setState(() {});
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
|
||||
@@ -1210,14 +1210,21 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
// Mark as loading to avoid duplicate requests
|
||||
_authorImages[displayName] = null;
|
||||
// Fetch in background using primary author name
|
||||
MetadataService.getAuthorImageUrl(lookupName).then((imageUrl) {
|
||||
if (mounted && imageUrl != null) {
|
||||
setState(() {
|
||||
_authorImages[displayName] = imageUrl;
|
||||
});
|
||||
}
|
||||
_fetchAuthorImage(displayName, lookupName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchAuthorImage(String displayName, String lookupName) async {
|
||||
try {
|
||||
final imageUrl = await MetadataService.getAuthorImageUrl(lookupName);
|
||||
if (mounted && imageUrl != null) {
|
||||
setState(() {
|
||||
_authorImages[displayName] = imageUrl;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore image fetch errors - authors will show without images
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3413,13 +3420,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
Widget _buildPodcastsTab(BuildContext context, S l10n) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
// PERF: Use select() to only rebuild when podcasts, loading state, or enabled providers changes
|
||||
// PERF: Use watch() - cannot use select() inside PageView.builder
|
||||
// Use podcastsUnfiltered to avoid double-filtering with MA's provider filter
|
||||
final (allPodcasts, isLoading, enabledProviders) = context.select<MusicAssistantProvider, (List<MediaItem>, bool, Set<String>)>(
|
||||
(p) => (p.podcastsUnfiltered, p.isLoadingPodcasts, p.enabledProviderIds.toSet()),
|
||||
);
|
||||
// Use read() for methods that don't need reactive updates
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final allPodcasts = maProvider.podcastsUnfiltered;
|
||||
final isLoading = maProvider.isLoadingPodcasts;
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
|
||||
if (isLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||
@@ -3717,13 +3723,12 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
Widget _buildRadioStationsTab(BuildContext context, S l10n) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
// PERF: Use select() to only rebuild when radio stations, loading state, or enabled providers changes
|
||||
// PERF: Use watch() - cannot use select() inside PageView.builder
|
||||
// Use radioStationsUnfiltered to avoid double-filtering with MA's provider filter
|
||||
final (allRadioStations, isLoading, enabledProviders) = context.select<MusicAssistantProvider, (List<MediaItem>, bool, Set<String>)>(
|
||||
(p) => (p.radioStationsUnfiltered, p.isLoadingRadio, p.enabledProviderIds.toSet()),
|
||||
);
|
||||
// Use read() for methods that don't need reactive updates
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
final allRadioStations = maProvider.radioStationsUnfiltered;
|
||||
final isLoading = maProvider.isLoadingRadio;
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
|
||||
if (isLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||
@@ -3965,11 +3970,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
|
||||
// ============ ARTISTS TAB ============
|
||||
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
||||
// Use select to only rebuild when specific fields change
|
||||
// Use watch instead of select - cannot use select inside PageView.builder
|
||||
// SyncService changes are handled by _onSyncServiceChanged listener
|
||||
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
|
||||
(p) => (p.isLoading, p.enabledProviderIds.toSet()),
|
||||
);
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final isLoading = provider.isLoading;
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
@@ -4202,11 +4207,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||
|
||||
// ============ ALBUMS TAB ============
|
||||
Widget _buildAlbumsTab(BuildContext context, S l10n) {
|
||||
// Use select to only rebuild when specific fields change
|
||||
// Use watch instead of select - cannot use select inside PageView.builder
|
||||
// SyncService changes are handled by _onSyncServiceChanged listener
|
||||
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
|
||||
(p) => (p.isLoading, p.enabledProviderIds.toSet()),
|
||||
);
|
||||
final provider = context.watch<MusicAssistantProvider>();
|
||||
final isLoading = provider.isLoading;
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
|
||||
@@ -590,12 +590,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
ListTile(
|
||||
leading: Icon(Icons.playlist_add, color: colorScheme.onSurface),
|
||||
title: Text(S.of(context)!.addToQueue, style: TextStyle(color: colorScheme.onSurface)),
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
// Add to queue on selected player
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player != null) {
|
||||
maProvider.addTracksToQueue(player.playerId, _tracks).then((_) {
|
||||
try {
|
||||
await maProvider.addTracksToQueue(player.playerId, _tracks);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -604,7 +605,11 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError(S.of(context)!.failedToAddToQueue(e.toString()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_showError(S.of(context)!.noPlayerSelected);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ class LibraryStatusService extends ChangeNotifier {
|
||||
required bool Function(dynamic item) getFavorite,
|
||||
required String Function(dynamic item) getProvider,
|
||||
required String Function(dynamic item) getItemId,
|
||||
bool notify = true,
|
||||
}) {
|
||||
bool changed = false;
|
||||
|
||||
@@ -130,7 +131,7 @@ class LibraryStatusService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
if (changed && notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -332,11 +332,11 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
);
|
||||
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
},
|
||||
onLongPressStart: (details) {
|
||||
|
||||
@@ -318,11 +318,11 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
);
|
||||
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
},
|
||||
onLongPressStart: (details) {
|
||||
|
||||
@@ -417,6 +417,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_controller.removeListener(_recordAnimationFrame);
|
||||
_lyricsScrollController.removeListener(_onLyricsScroll);
|
||||
|
||||
_expansionNotifyTimer?.cancel();
|
||||
_controller.dispose();
|
||||
_queuePanelController.dispose();
|
||||
_sleepTimerPanelController.dispose();
|
||||
@@ -615,6 +616,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// PERF Phase 4: Track last notified value to avoid unnecessary object creation
|
||||
double _lastNotifiedProgress = -1;
|
||||
Timer? _expansionNotifyTimer;
|
||||
double? _pendingProgress;
|
||||
|
||||
void _notifyExpansionProgress() {
|
||||
final progress = _controller.value;
|
||||
@@ -623,11 +626,23 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
if ((progress - _lastNotifiedProgress).abs() >= 0.01 ||
|
||||
progress == 0.0 || progress == 1.0) {
|
||||
_lastNotifiedProgress = progress;
|
||||
playerExpansionNotifier.value = PlayerExpansionState(
|
||||
progress,
|
||||
_currentExpandedBgColor,
|
||||
_currentExpandedPrimaryColor,
|
||||
);
|
||||
_pendingProgress = progress;
|
||||
|
||||
// Cancel any pending timer to avoid queue buildup
|
||||
_expansionNotifyTimer?.cancel();
|
||||
|
||||
// Debounce with a short delay to ensure we're outside build phase
|
||||
_expansionNotifyTimer = Timer(const Duration(milliseconds: 1), () {
|
||||
if (mounted && _pendingProgress != null) {
|
||||
playerExpansionNotifier.value = PlayerExpansionState(
|
||||
_pendingProgress!,
|
||||
_currentExpandedBgColor,
|
||||
_currentExpandedPrimaryColor,
|
||||
);
|
||||
}
|
||||
_expansionNotifyTimer = null;
|
||||
_pendingProgress = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1641,7 +1656,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_slideController.addListener(animateToTarget);
|
||||
_slideController.duration = const Duration(milliseconds: 150);
|
||||
|
||||
_slideController.forward().then((_) {
|
||||
_slideController.forward().then((_) async {
|
||||
if (!mounted) return;
|
||||
|
||||
_slideController.removeListener(animateToTarget);
|
||||
|
||||
@@ -109,6 +109,8 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Whether the item is currently a favorite
|
||||
bool get isFavorite => LibraryStatusService.instance.isFavorite(libraryItemKey);
|
||||
|
||||
bool _isStatusUpdatePending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -122,10 +124,17 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
|
||||
void _onLibraryStatusChanged() {
|
||||
// Trigger rebuild to pick up new status values
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
// Use addPostFrameCallback to ensure setState is called AFTER the build phase
|
||||
// This prevents "setState during build" errors
|
||||
if (_isStatusUpdatePending) return;
|
||||
|
||||
_isStatusUpdatePending = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
_isStatusUpdatePending = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Set library status optimistically and return previous value for rollback
|
||||
|
||||
@@ -80,9 +80,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
super.initState();
|
||||
|
||||
// Load hint settings
|
||||
SettingsService.getShowHints().then((value) {
|
||||
if (mounted) setState(() => _showHints = value);
|
||||
});
|
||||
_loadHintSettings();
|
||||
|
||||
_revealController = AnimationController(
|
||||
vsync: this,
|
||||
@@ -108,15 +106,21 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
_revealController.forward();
|
||||
|
||||
// Auto-refresh player data
|
||||
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
||||
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
||||
if (mounted) {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
provider.refreshPlayers();
|
||||
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
|
||||
await provider.preloadAllPlayerTracks();
|
||||
_preloadColorsForPlayers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadHintSettings() async {
|
||||
final value = await SettingsService.getShowHints();
|
||||
if (mounted) setState(() => _showHints = value);
|
||||
}
|
||||
|
||||
/// Extract accent colors from album art for each player
|
||||
Future<void> _preloadColorsForPlayers() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
@@ -164,7 +168,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
}
|
||||
|
||||
/// Animate dismissal and call callback when done
|
||||
void dismiss() {
|
||||
Future<void> dismiss() async {
|
||||
// Trigger single bounce on mini player when cards are ~70% collapsed
|
||||
// This creates the effect of cards "landing" on the mini player
|
||||
bool bounceFired = false;
|
||||
@@ -188,10 +192,9 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
|
||||
_revealController.duration = const Duration(milliseconds: 150);
|
||||
_revealController.addListener(checkBounce);
|
||||
_revealController.reverse().then((_) {
|
||||
_revealController.removeListener(checkBounce);
|
||||
widget.onDismiss();
|
||||
});
|
||||
await _revealController.reverse();
|
||||
_revealController.removeListener(checkBounce);
|
||||
widget.onDismiss();
|
||||
}
|
||||
|
||||
/// Check if scroll is at top (or not scrollable)
|
||||
|
||||
@@ -207,11 +207,10 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
|
||||
_dropdownController.forward();
|
||||
}
|
||||
|
||||
void _closeTransferDropdown() {
|
||||
void _closeTransferDropdown() async {
|
||||
if (!_showingTransferDropdown) return;
|
||||
_dropdownController.reverse().then((_) {
|
||||
if (mounted) setState(() => _showingTransferDropdown = false);
|
||||
});
|
||||
await _dropdownController.reverse();
|
||||
if (mounted) setState(() => _showingTransferDropdown = false);
|
||||
}
|
||||
|
||||
void _handleTransferQueue(BuildContext context) {
|
||||
|
||||
@@ -255,11 +255,11 @@ class _PlaylistCardState extends State<PlaylistCard> with LibraryStatusMixin {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
);
|
||||
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
},
|
||||
onLongPressStart: (details) {
|
||||
|
||||
@@ -261,11 +261,11 @@ class _PodcastCardState extends State<PodcastCard> with LibraryStatusMixin {
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
);
|
||||
|
||||
// Reset after navigation debounce delay
|
||||
Future.delayed(Timings.navigationDebounce, () {
|
||||
if (mounted) _isNavigating = false;
|
||||
});
|
||||
},
|
||||
onLongPressStart: (details) {
|
||||
|
||||
Reference in New Issue
Block a user