Compare commits

...
17 Commits
Author SHA1 Message Date
Chris 4e27e6a4c2 On audit: WIP: multi-provider debugging attempts 2026-02-07 14:40:47 +00:00
Chris b136b26b7e index on audit: 404828c Fix setState during build using timer debounce 2026-02-07 14:40:47 +00:00
ChrisandClaude Opus 4.6 404828c47c Fix setState during build using timer debounce
Replace Future.microtask with Timer-based debounce (50ms delay) to
ensure setState is called well outside the build phase. Also properly
cancels the timer on dispose to prevent memory leaks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:19:10 +00:00
ChrisandClaude Opus 4.6 48afbf9d50 Update status: red screen bug fixed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:11:55 +00:00
ChrisandClaude Opus 4.6 ebf4e53328 Fix setState and context.select errors causing red screen
- Replace context.select with context.watch in PageView.builder tabs
  (context.select cannot be used inside PageView.builder/SliverList)
- Fix LibraryStatusMixin setState during build error using Future.microtask
- Both fixes resolve the red screen issue after login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:11:42 +00:00
ChrisandClaude Opus 4.6 a6d6eb8d55 Fix QueueManagerService circular dependency in multi-provider architecture
- Make QueueManagerService optional in PlayerProvider to break circular dependency
- Set queueManager after initialization in main.dart
- Add castToSendspinIdMap getter to PlayerProvider for external access
- Wire up QueueManagerService with proper callbacks to provider state

This allows providers to be properly initialized without circular references
while maintaining the ability to access shared state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:53:59 +00:00
ChrisandClaude Opus 4.6 cd8c5a66e4 Complete multi-provider architecture
Created two core providers to complete the multi-provider split:

1. PlayerProvider (588 lines)
   - Player selection and available players list
   - Current playback state and track management
   - Play/pause/stop/next/previous/seek controls
   - Queue operations delegation
   - Position tracking integration
   - Sleep timer integration
   - Audio handler notification updates
   - Cast-to-Sendspin mapping support

2. LocalPlayerProvider (370 lines)
   - LocalPlayerService management
   - SendspinService integration (MA 2.7.0b20+)
   - PcmAudioPlayer for raw audio streaming
   - Player registration with Music Assistant
   - Volume and power control
   - Event handling from Sendspin
   - State reporting to Sendspin

**Multi-Provider Split Complete!**

All 7 focused providers created:
- ConnectionProvider (245 lines) - WebSocket, auth, connection state
- UIStateProvider (166 lines) - Search, filters, loading state
- LibraryProvider (290 lines) - Artists, albums, tracks, podcasts
- PlayerProvider (588 lines) - Player selection, controls, queue
- LocalPlayerProvider (370 lines) - Local audio engine, Sendspin
- SleepTimerProvider (133 lines) - Sleep timer functionality
- LocaleProvider (44 lines) - Locale/language settings
- NavigationProvider (40 lines) - Navigation state

Total new provider code: ~1,876 lines across 7 focused providers
Main provider can now act as a facade for gradual migration.

Next steps: Integrate into main.dart, migrate consumers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:24:53 +00:00
ChrisandClaude Opus 4.6 dadfe65e5a Create multi-provider architecture foundation
Created three new focused providers to begin splitting the monolithic
MusicAssistantProvider (5928 lines) into smaller, more maintainable
components:

1. ConnectionProvider (245 lines)
   - WebSocket connection to Music Assistant server
   - Authentication flow (tokens and credentials)
   - Connection state management
   - Event stream subscriptions

2. UIStateProvider (167 lines)
   - Search state (query and results)
   - Provider and player filters
   - User-controlled provider enable/disable
   - Loading states
   - Home refresh counter

3. LibraryProvider (290 lines)
   - Artists, albums, tracks lists
   - Radio stations and podcasts
   - Library loading and refresh
   - Provider filtering
   - Background sync

These providers will be integrated into the app gradually, with the main
MusicAssistantProvider acting as a facade for backward compatibility during
the transition period.

Next steps: Create PlayerProvider and LocalPlayerProvider, then integrate
all providers into main.dart and update consumers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:18:02 +00:00
ChrisandClaude Opus 4.6 0a853bd862 Extract and centralize constant definitions
Added new constant classes to timings.dart:
- ImageSizes: defaultSize (256), highRes (512), max (1024)
- VolumeConstants: min (0), max (100), range (100)
- PlayerStates: playing, paused, idle, off
- RepeatModes: off, all, one

Updated MusicAssistantProvider and QueueControls to use these
constants instead of magic numbers and string literals.

This improves code maintainability by:
- Eliminating magic numbers
- Providing single source of truth for common values
- Making code more self-documenting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:04:33 +00:00
ChrisandClaude Opus 4.6 694b7127a0 Extract queue controls utility
Extracted repeat mode cycling logic from MusicAssistantProvider
into QueueControls utility (26 lines). This isolates queue
playback control logic.

This reduces the main provider from 5941 to 5928 lines (-13 lines).

Extracted methods:
- getNextRepeatMode(String? currentMode) - Get next repeat mode in cycle
- isValidRepeatMode(String? mode) - Validate repeat mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:54:53 +00:00
ChrisandClaude Opus 4.6 4cbfd6a9e0 Extract network utilities
Extracted network-related utility method from MusicAssistantProvider
into NetworkUtils (27 lines). This isolates the logic for checking
if a hostname is a local/private network address.

This reduces the main provider from 5954 to 5941 lines (-13 lines).

Extracted methods:
- isLocalNetworkHost(String host) - Check if hostname is local/private

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:52:12 +00:00
ChrisandClaude Opus 4.6 8bf0d23972 Extract search service
Extracted search functionality from MusicAssistantProvider into
SearchService (96 lines). This isolates search operations with caching:

- searchWithCache - Search with caching support
- clearAllDetailCaches - Clear all search/detail caches
- isSearchCacheValid - Check cache validity
- getCachedSearchResults - Get cached results

This reduces the main provider from 5979 to 5954 lines (-25 lines).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:48:16 +00:00
ChrisandClaude Opus 4.6 c17b8b5c5e Extract player sort utility
Extracted player sorting and filtering logic from MusicAssistantProvider
into PlayerSortUtility (162 lines). This isolates complex player list
management operations:

- Smart sort logic: local player first, then playing, then on, then off
- Default alphabetical sort fallback
- Player filtering: excludes MA Mobile, MA Web UI, "This Device" players
- Best player auto-selection: last selected > last active > builtin > first

This reduces the main provider from 5999 to 5979 lines (-20 lines).

Extracted methods:
- sortPlayers(List<Player>, {bool smartSort, String? builtinPlayerId})
- filterPlayers(List<Player>, {String? builtinPlayerId})
- selectBestPlayer({...}) - intelligent player auto-selection
- _statusPriority(Player) - private helper for sort order

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:45:04 +00:00
ChrisandClaude Opus 4.6 4e3134df9c Extract queue management service
Extracted queue-related logic from MusicAssistantProvider into a new
QueueManagerService (221 lines). This isolates complex queue operations
including:

- Queue fetching from API with player ID translation
  - Handles group child players (fetches leader's queue)
  - Cast-to-Sendspin ID translation for queue operations
- Queue caching to database for instant display
- Queue restoration from cache on app resume
- Track validation for queue items (filters playable tracks)

This reduces the main provider from 6110 to 5999 lines (-111 lines).

Extracted methods:
- getQueue(String playerId)
- getCachedQueue(String playerId)
- persistQueueToDatabase(String playerId, PlayerQueue queue)
- restoreQueueFromCache(String playerId)
- _validateQueueTracks(List<QueueItem> queueItems)
- _getEffectivePlayerId(String playerId)
- _translateSendspinToCastId(...)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:37:03 +00:00
ChrisandClaude Opus 4.6 c4b80b9d33 Extract player sync state utility
Third incremental extraction to reduce monolithic provider size:
- Created PlayerSyncState utility (135 lines) for sync state determination
- Extracted isPlayerManuallySynced method with complex business logic
- Added helper methods: findPlayer, findSyncTarget, isGroupPlayer, isManualSyncLeader
- Reduced main provider from 6158 to 6110 lines

PlayerSyncState provides:
- Clean separation of sync state logic from UI provider
- Testable business rules for manual vs pre-configured groups
- Cast-to-Sendspin ID translation support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:25:28 +00:00
ChrisandClaude Opus 4.6 d28c6ab556 Extract services from MusicAssistantProvider
First incremental extraction to reduce monolithic provider size:
- Created ImageHelperService (97 lines) for image/artwork operations
- Created ProviderFilterService (319 lines) for provider filtering logic
- Reduced main provider from 6180 to 6158 lines

ImageHelperService methods:
- getImageUrl, getPodcastImageUrl, getArtistImageUrlWithFallback
- getProviderIconSvg, getCachedArtworkUrl
- Podcast cover cache management

ProviderFilterService methods:
- filterByProvider, filterPlayers, isItemAllowedByProviderFilter
- getProvidersWithArtists/Albums/Tracks/Playlists/etc.
- getRelevantProvidersForCategory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:20:20 +00:00
ChrisandClaude Opus 4.6 ccfea4168c Fix critical and high priority issues from codebase audit
Critical fixes:
- Remove empty setState() in debug_log_screen.dart
- Add disposed flag to sleep_timer_provider.dart to prevent
  notifyListeners() after dispose

High priority fixes:
- Convert .then() chains to async/await for better error handling
  in playlist_details_screen.dart, album_details_screen.dart,
  expandable_player.dart, player_reveal_overlay.dart,
  queue_panel.dart, and all card widgets
- Extract _loadHintSettings() and _fetchAuthorImage() methods

Also includes:
- CODEBASE_AUDIT_REPORT.md with comprehensive audit findings

Tested on device - no regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:54:08 +00:00
31 changed files with 3740 additions and 482 deletions
+529
View File
@@ -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**
+212
View File
@@ -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
+62
View File
@@ -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
View File
@@ -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
+287
View File
@@ -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');
}
}
}
+371
View File
@@ -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();
}
}
+98 -350
View File
@@ -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);
}
+604
View File
@@ -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();
}
}
+5 -1
View File
@@ -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();
}
+166
View File
@@ -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();
}
}
+8 -3
View File
@@ -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);
}
-1
View File
@@ -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,
+31 -26
View File
@@ -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;
+8 -3
View File
@@ -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);
}
+97
View File
@@ -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();
}
}
+2 -1
View File
@@ -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();
}
}
+319
View File
@@ -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(' ');
}
}
+221
View File
@@ -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);
}
}
+96
View File
@@ -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': [],
};
}
}
+27
View File
@@ -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
}
}
+162
View File
@@ -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',
);
}
+135
View File
@@ -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);
}
}
+30
View File
@@ -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;
}
}
+5 -5
View File
@@ -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) {
+5 -5
View File
@@ -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) {
+21 -6
View File
@@ -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);
+13 -4
View File
@@ -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
+13 -10
View File
@@ -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)
+3 -4
View File
@@ -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) {
+5 -5
View File
@@ -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) {
+5 -5
View File
@@ -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) {