Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
844bce2eea | ||
|
|
49ccbc618a | ||
|
|
90cb699566 | ||
|
|
92777197ac | ||
|
|
688b6354a8 | ||
|
|
d29ce362e7 | ||
|
|
0a385af168 | ||
|
|
c448c4ab19 | ||
|
|
ea7144d81d | ||
|
|
48afca040e | ||
|
|
c85f19d1d3 | ||
|
|
1f3a3b3c1d | ||
|
|
501fe0967a | ||
|
|
f17bfdfa55 | ||
|
|
be1d7c3b50 | ||
|
|
1d7cbdeeed | ||
|
|
d62e1169ea | ||
|
|
3970839133 | ||
|
|
8181e75a08 | ||
|
|
9600f4bcfe | ||
|
|
a2b8b1eeef | ||
|
|
0263c75804 | ||
|
|
469e77c572 | ||
|
|
577b03bbea | ||
|
|
7bbba90fff | ||
|
|
6f66a1ebff | ||
|
|
0ceab80a14 | ||
|
|
efb84a4e50 | ||
|
|
731f45bea9 | ||
|
|
32e61f109e | ||
|
|
cd9e82b1fd | ||
|
|
04322cf768 | ||
|
|
e6349c309b | ||
|
|
00f065fbe9 | ||
|
|
e440f7b1d7 | ||
|
|
5a73d2d65b | ||
|
|
3b5a8631a8 | ||
|
|
1f3a01c22f | ||
|
|
27e1b8b746 | ||
|
|
9be3737a9d | ||
|
|
882e8b2ea4 | ||
|
|
aef9423b42 | ||
|
|
c0138828a7 | ||
|
|
53225138a2 | ||
|
|
ebc3288bfc | ||
|
|
5bd0cef76e | ||
|
|
9f399b438f | ||
|
|
63473226f5 | ||
|
|
e3d257a6dc | ||
|
|
671b31de61 | ||
|
|
67a4ebd771 | ||
|
|
23af5379a6 | ||
|
|
86bdad5534 | ||
|
|
7ee27f6e75 | ||
|
|
633f489bfa | ||
|
|
61503cbe49 | ||
|
|
0b49c5158c | ||
|
|
d7b7bbcf0d | ||
|
|
3503978185 | ||
|
|
020ed250f4 | ||
|
|
e5be64a6cd | ||
|
|
b85fadc551 | ||
|
|
3b8339f94f | ||
|
|
c00fb3dc78 | ||
|
|
ff34479169 | ||
|
|
d7127ea7da | ||
|
|
29b4887d0d | ||
|
|
18603c34e1 | ||
|
|
5ed2bf7725 | ||
|
|
67a99c88a0 | ||
|
|
ffbb41580b | ||
|
|
6942b56cfb | ||
|
|
14ba524ed0 | ||
|
|
49fc2a77e7 | ||
|
|
6632fa14c6 | ||
|
|
a0cac2174f | ||
|
|
056d1fb854 | ||
|
|
a4cc7115bf | ||
|
|
1f3188fa6c | ||
|
|
9527ba341e | ||
|
|
79e9e02f53 | ||
|
|
d14a4b99c6 | ||
|
|
7e72b08f44 | ||
|
|
801952482f | ||
|
|
bae1154bcf | ||
|
|
0342caaf5c | ||
|
|
d85dc9e592 | ||
|
|
9338828ac7 | ||
|
|
d920abd241 | ||
|
|
02f7d501ff | ||
|
|
5f72cca25e | ||
|
|
c2f75797e1 | ||
|
|
3d982892de | ||
|
|
c48f9d19c4 | ||
|
|
e4f24766d2 | ||
|
|
d7ac8befac | ||
|
|
8a2a3b8a14 | ||
|
|
ea1133c4d6 | ||
|
|
cb2595e36a | ||
|
|
70e983de51 | ||
|
|
251579d32a | ||
|
|
9d5e8abc11 | ||
|
|
225a1bd010 | ||
|
|
ef5f6b6483 | ||
|
|
4a369fa5f5 | ||
|
|
52489fa97a | ||
|
|
5c52ef2738 | ||
|
|
447b8c8a93 | ||
|
|
eb34663d3f | ||
|
|
2d359db25c | ||
|
|
0845d6d29a | ||
|
|
c80a86abe8 | ||
|
|
8358274f73 | ||
|
|
9200af705a | ||
|
|
1eed678deb | ||
|
|
4ab31c11df | ||
|
|
4c7ecfbf88 | ||
|
|
d376fd3725 | ||
|
|
a7893e6ad9 | ||
|
|
d3474a2665 | ||
|
|
5bfa91c566 | ||
|
|
884b457139 | ||
|
|
5e2599bdb8 | ||
|
|
4dec8f43a2 | ||
|
|
ee7a08a859 | ||
|
|
4351a32cde | ||
|
|
587115dc2b | ||
|
|
862c9d5ef3 | ||
|
|
ed7613e6ad | ||
|
|
74d2b1b1eb | ||
|
|
951c8a68ea | ||
|
|
c24dec51c5 | ||
|
|
11ed7e7d34 | ||
|
|
40f72f8db9 | ||
|
|
5e2fb6ff32 | ||
|
|
47894e0bb9 | ||
|
|
c9f7321bec | ||
|
|
277020616f | ||
|
|
cd43ad0868 | ||
|
|
98175719bf | ||
|
|
e5274ca72e | ||
|
|
6a78405dc1 | ||
|
|
49e8bbbb30 | ||
|
|
beeebeb996 | ||
|
|
49077b9858 | ||
|
|
9dbcf9971f | ||
|
|
c5bcc509e0 | ||
|
|
2f933da413 | ||
|
|
2fdd3b4286 | ||
|
|
779810674b | ||
|
|
0dc069b071 | ||
|
|
ca7b5d8d35 | ||
|
|
b15de9775f | ||
|
|
5fcfe1d3b3 | ||
|
|
6a8a4972dd | ||
|
|
73a23594cf | ||
|
|
02d7ea12cb | ||
|
|
601dd8ac7a |
@@ -0,0 +1,3 @@
|
||||
# These are the funding models supported by Ensemble
|
||||
github: CollotsSpot
|
||||
custom: https://ko-fi.com/collotsspot
|
||||
@@ -31,8 +31,8 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.24.0'
|
||||
channel: 'stable'
|
||||
flutter-version: '3.38.0'
|
||||
channel: 'master'
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
@@ -63,10 +63,7 @@ jobs:
|
||||
|
||||
- name: Setup signing keystore
|
||||
run: |
|
||||
# Decode keystore from secret (persistent key for consistent signatures)
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
|
||||
|
||||
# Create key.properties file
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword=${{ secrets.KEY_PASSWORD }}
|
||||
@@ -75,12 +72,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: |
|
||||
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
|
||||
mkdir -p ~/.gradle
|
||||
cp android/init.gradle ~/.gradle/init.gradle
|
||||
|
||||
flutter build apk --release
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -91,10 +83,5 @@ jobs:
|
||||
|
||||
- name: Upload APK summary
|
||||
run: |
|
||||
echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📱 **APK Location:** \`build/app/outputs/flutter-apk/app-release.apk\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📦 **Download:** Check the artifacts section below" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## APK Build Successful!" >> $GITHUB_STEP_SUMMARY
|
||||
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
# Ensemble Codebase Intensive Audit Prompt
|
||||
|
||||
## Overview
|
||||
|
||||
You are tasked with conducting an intensive, comprehensive audit of the **Ensemble** Flutter application - a Music Assistant client for Android with multi-device synchronization, local playback, and Sendspin raw audio streaming capabilities.
|
||||
|
||||
**Codebase Statistics:**
|
||||
- 90 Dart files, ~40,583 lines of code
|
||||
- 18 screens, 27 services, 6 providers
|
||||
- Architecture: Provider pattern with ChangeNotifier
|
||||
- Audio: just_audio + flutter_pcm_sound + audio_service
|
||||
- Networking: WebSocket (JSON-RPC) + HTTP + Sendspin binary protocol
|
||||
|
||||
---
|
||||
|
||||
## Audit Scope
|
||||
|
||||
Perform a thorough audit covering ALL of the following areas. For each issue found, provide:
|
||||
1. **File path and line number(s)**
|
||||
2. **Severity**: CRITICAL / HIGH / MEDIUM / LOW
|
||||
3. **Description** of the issue
|
||||
4. **Impact** if left unfixed
|
||||
5. **Recommended fix** with code example where applicable
|
||||
|
||||
---
|
||||
|
||||
## SECTION 1: SECURITY AUDIT
|
||||
|
||||
### 1.1 Credential Storage (CRITICAL PRIORITY)
|
||||
Audit `/lib/services/settings_service.dart` for:
|
||||
- [ ] Plaintext password storage in SharedPreferences (lines 209-222)
|
||||
- [ ] Auth tokens stored without encryption
|
||||
- [ ] Serialized credentials in JSON format (lines 169-190)
|
||||
|
||||
**Expected Finding:** Credentials stored insecurely. Recommend flutter_secure_storage.
|
||||
|
||||
### 1.2 Certificate Validation
|
||||
Audit `/lib/services/auth/auth_manager.dart` for:
|
||||
- [ ] HTTPS redirect handling for local IPs
|
||||
- [ ] Certificate validation disabled scenarios
|
||||
- [ ] MITM vulnerability exposure
|
||||
|
||||
### 1.3 Authentication Strategies
|
||||
Audit all files in `/lib/services/auth/`:
|
||||
- [ ] `basic_auth_strategy.dart` - Base64 encoding (not encryption)
|
||||
- [ ] `authelia_strategy.dart` - Session cookie handling, timing attack (line 159)
|
||||
- [ ] `ma_auth_strategy.dart` - Token refresh mechanism
|
||||
- [ ] `no_auth_strategy.dart` - Public server exposure
|
||||
|
||||
### 1.4 Input Validation
|
||||
Audit `/lib/services/music_assistant_api.dart`:
|
||||
- [ ] URI construction without validation (lines 1346-1360)
|
||||
- [ ] Provider/itemId injection risks
|
||||
- [ ] WebSocket message validation
|
||||
|
||||
### 1.5 Information Disclosure
|
||||
- [ ] Debug logs exposing sensitive data (check all `_logger.log()` calls)
|
||||
- [ ] Server version exposure via `server_info`
|
||||
- [ ] Error messages leaking technical details
|
||||
|
||||
---
|
||||
|
||||
## SECTION 2: MEMORY MANAGEMENT AUDIT
|
||||
|
||||
### 2.1 Static Memory Leaks
|
||||
Audit `/lib/models/player.dart`:
|
||||
- [ ] `_playerCreationTimes` static map (lines 96-144) - grows unbounded
|
||||
- [ ] Cleanup only removes 50 of 100+ entries
|
||||
|
||||
### 2.2 Unbounded Caches
|
||||
Audit `/lib/services/cache_service.dart`:
|
||||
- [ ] `_albumTracksCache` - no size limit
|
||||
- [ ] `_artistAlbumsCache` - no TTL expiration
|
||||
- [ ] `_searchCache` - never evicted
|
||||
- [ ] `_playerTrackCache` - only cleared on disconnect
|
||||
|
||||
### 2.3 Stream/Timer Cleanup
|
||||
Audit `/lib/providers/music_assistant_provider.dart`:
|
||||
- [ ] `_playerStateTimer` disposal (line 4435-4445)
|
||||
- [ ] `_notificationPositionTimer` multiple creation paths (lines 2925-2940)
|
||||
- [ ] `_localPlayerEventSubscription` cleanup
|
||||
- [ ] `_playerUpdatedEventSubscription` cleanup
|
||||
- [ ] `_playerAddedEventSubscription` cleanup
|
||||
|
||||
Audit `/lib/services/hardware_volume_service.dart`:
|
||||
- [ ] `_volumeUpController` never closed (lines 74-85)
|
||||
- [ ] `_volumeDownController` never closed
|
||||
|
||||
### 2.4 Stream Subscription Leaks
|
||||
Audit `/lib/providers/music_assistant_provider.dart`:
|
||||
- [ ] `_api!.connectionState.listen()` - subscription not stored
|
||||
- [ ] Old subscriptions not cancelled before new API instance
|
||||
|
||||
---
|
||||
|
||||
## SECTION 3: RACE CONDITION AUDIT
|
||||
|
||||
### 3.1 Player State Dual Updates
|
||||
Audit `/lib/providers/music_assistant_provider.dart`:
|
||||
- [ ] Timer polling (every 5s) vs WebSocket events racing
|
||||
- [ ] `_updatePlayerState()` called from both paths
|
||||
- [ ] No mutex/lock protection
|
||||
|
||||
### 3.2 Async Method Reentrancy
|
||||
Audit `selectPlayer()` method:
|
||||
- [ ] `async` method without guard against concurrent calls
|
||||
- [ ] `_selectedPlayer` modified before and after `await`
|
||||
- [ ] State could change during await
|
||||
|
||||
### 3.3 Fire-and-Forget Operations
|
||||
Search for `unawaited(` and `() async {` patterns:
|
||||
- [ ] `_persistPlaybackState()` - database save not awaited
|
||||
- [ ] Pause operations fire-and-forget (lines 3876-3894)
|
||||
- [ ] Preload operations (line 2707)
|
||||
|
||||
### 3.4 PCM Player State Machine
|
||||
Audit `/lib/services/pcm_audio_player.dart`:
|
||||
- [ ] 9-state machine with complex transitions (lines 32-37)
|
||||
- [ ] Auto-recovery race condition (lines 215-229)
|
||||
- [ ] Feed loop during pause transition (lines 174-179)
|
||||
|
||||
---
|
||||
|
||||
## SECTION 4: ERROR HANDLING AUDIT
|
||||
|
||||
### 4.1 Silent Exception Handlers (CRITICAL)
|
||||
Find all instances of:
|
||||
```dart
|
||||
} catch (e) {}
|
||||
} catch (_) {}
|
||||
```
|
||||
|
||||
Known locations:
|
||||
- [ ] `music_assistant_provider.dart` (multiple)
|
||||
- [ ] `player_provider.dart` (3 instances)
|
||||
- [ ] `recently_played_service.dart`
|
||||
|
||||
### 4.2 Missing Error Recovery
|
||||
- [ ] No exponential backoff on connection retry
|
||||
- [ ] No retry logic for failed API calls
|
||||
- [ ] WebSocket timeout handling (30 seconds - too long?)
|
||||
|
||||
### 4.3 Null Safety Violations
|
||||
Find all `!` (null assertion) operators:
|
||||
- [ ] `search_screen.dart` (20+ instances)
|
||||
- [ ] `models/media_item.dart`
|
||||
- [ ] `models/player.dart`
|
||||
- [ ] `item.mediaItem! as Artist` patterns
|
||||
|
||||
---
|
||||
|
||||
## SECTION 5: ARCHITECTURAL ISSUES
|
||||
|
||||
### 5.1 God Class
|
||||
Audit `/lib/providers/music_assistant_provider.dart`:
|
||||
- [ ] 4,469 lines - violates Single Responsibility
|
||||
- [ ] 386 conditional branches
|
||||
- [ ] Mixing: connection, players, library, sync, audio, position tracking
|
||||
|
||||
**Recommend splitting into:**
|
||||
- ConnectionProvider (~500 LOC)
|
||||
- PlayerManager (~800 LOC)
|
||||
- LibraryProvider (~1000 LOC)
|
||||
- LocalPlaybackManager (~600 LOC)
|
||||
|
||||
### 5.2 Oversized Widget Files
|
||||
- [ ] `expandable_player.dart` - 2,485 lines (should be <500)
|
||||
- [ ] `global_player_overlay.dart` - 737 lines
|
||||
- [ ] `new_library_screen.dart` - 2,521 lines
|
||||
|
||||
### 5.3 Global Key Anti-Pattern
|
||||
Audit `/lib/widgets/global_player_overlay.dart`:
|
||||
- [ ] `globalPlayerKey` for state access
|
||||
- [ ] `_overlayStateKey` for overlay state
|
||||
- [ ] Should use Provider/context instead
|
||||
|
||||
### 5.4 Primitive Obsession
|
||||
- [ ] View modes as strings ('grid2', 'grid3', 'list')
|
||||
- [ ] Media types as strings ('track', 'album', 'artist')
|
||||
- [ ] Should be strongly-typed enums
|
||||
|
||||
---
|
||||
|
||||
## SECTION 6: CODE QUALITY ISSUES
|
||||
|
||||
### 6.1 Testing (CRITICAL)
|
||||
- [ ] **ZERO test files exist** - confirm this
|
||||
- [ ] No unit tests, widget tests, or integration tests
|
||||
- [ ] flutter_test dependency exists but unused
|
||||
|
||||
### 6.2 Hardcoded Values
|
||||
Find and document all:
|
||||
- [ ] Color hex codes (50+ instances of `Color(0xFF...`)
|
||||
- [ ] Duration values not using `Timings` constants
|
||||
- [ ] Magic numbers (16, 4, 5, 100, 50)
|
||||
- [ ] String literals for media types
|
||||
|
||||
### 6.3 Mixed Async Patterns
|
||||
- [ ] `.then()` chains alongside async/await
|
||||
- [ ] `.whenComplete()` usage
|
||||
- [ ] Consolidate to async/await
|
||||
|
||||
### 6.4 Dead Code
|
||||
- [ ] `@Deprecated` methods still implemented
|
||||
- [ ] `animation_debugger.dart` - dev-only?
|
||||
- [ ] Unused imports
|
||||
|
||||
### 6.5 TODO Comments
|
||||
Document all actionable TODOs:
|
||||
- [ ] `queue_screen.dart:214` - queue item removal
|
||||
- [ ] `audiobook_detail_screen.dart:261,312` - progress sync
|
||||
- [ ] `music_assistant_provider.dart:793,799` - playlist modification
|
||||
|
||||
---
|
||||
|
||||
## SECTION 7: UI/UX AUDIT
|
||||
|
||||
### 7.1 Accessibility (CRITICAL)
|
||||
Audit for missing:
|
||||
- [ ] `Semantics` widgets on interactive elements
|
||||
- [ ] `Tooltip` on icon buttons
|
||||
- [ ] Screen reader support
|
||||
- [ ] Touch target sizes (minimum 48dp)
|
||||
- [ ] Color contrast validation (WCAG AA)
|
||||
|
||||
### 7.2 Performance Issues
|
||||
Audit `/lib/widgets/expandable_player.dart`:
|
||||
- [ ] No `RepaintBoundary` wrappers
|
||||
- [ ] 55+ `setState()` calls
|
||||
- [ ] 3 AnimationControllers with multiple listeners
|
||||
- [ ] Title height cache invalidation (lines 118+)
|
||||
|
||||
### 7.3 Double Opacity Bug
|
||||
Audit `/lib/widgets/player/player_controls.dart`:
|
||||
```dart
|
||||
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
||||
.withOpacity(expandedElementsOpacity),
|
||||
```
|
||||
- [ ] Double `.withOpacity()` application
|
||||
|
||||
### 7.4 Missing Features
|
||||
- [ ] No landscape orientation support
|
||||
- [ ] No tablet layout optimization
|
||||
- [ ] No loading skeleton animations
|
||||
- [ ] No haptic feedback (except one location)
|
||||
|
||||
---
|
||||
|
||||
## SECTION 8: AUDIO PLAYBACK AUDIT
|
||||
|
||||
### 8.1 Position Interpolation
|
||||
Audit `/lib/services/position_tracker.dart`:
|
||||
- [ ] No timeout on anchor updates - unbounded drift possible
|
||||
- [ ] Duration interpolation can exceed track duration
|
||||
- [ ] Stale timestamp handling (>30 seconds)
|
||||
|
||||
### 8.2 Sendspin Protocol
|
||||
Audit `/lib/services/sendspin_service.dart`:
|
||||
- [ ] Proxy auth retry without deduplication (lines 188-209)
|
||||
- [ ] WebSocket binary frame parsing
|
||||
- [ ] Clock synchronization accuracy
|
||||
|
||||
### 8.3 Audio Resource Cleanup
|
||||
Audit `/lib/services/audio/massiv_audio_handler.dart`:
|
||||
- [ ] Audio session interrupt listeners not disposed
|
||||
- [ ] Audio focus not explicitly released
|
||||
|
||||
### 8.4 Mixed Playback Modes
|
||||
- [ ] just_audio initialized but unused during Sendspin
|
||||
- [ ] Resource allocation for unused player
|
||||
- [ ] Potential audio focus conflicts
|
||||
|
||||
---
|
||||
|
||||
## SECTION 9: NETWORKING AUDIT
|
||||
|
||||
### 9.1 WebSocket Lifecycle
|
||||
Audit `/lib/services/music_assistant_api.dart`:
|
||||
- [ ] Heartbeat interval (30s) - appropriate?
|
||||
- [ ] Reconnection logic and timing
|
||||
- [ ] Pending request cleanup on disconnect
|
||||
|
||||
### 9.2 Caching Strategy
|
||||
- [ ] No incremental/delta sync (full library every 5 minutes)
|
||||
- [ ] Cache staleness not communicated to UI
|
||||
- [ ] No query result pagination (fetches up to 5000 items)
|
||||
|
||||
### 9.3 Rate Limiting
|
||||
- [ ] No client-side rate limiting
|
||||
- [ ] Search requests not throttled
|
||||
- [ ] Could overload server
|
||||
|
||||
### 9.4 Offline Support
|
||||
Audit `/lib/services/offline_action_queue.dart`:
|
||||
- [ ] Queue execution not atomic (lines 118-130)
|
||||
- [ ] Action could execute twice on crash
|
||||
- [ ] Queue stored unencrypted
|
||||
|
||||
---
|
||||
|
||||
## SECTION 10: DATABASE AUDIT
|
||||
|
||||
### 10.1 Schema Review
|
||||
Audit `/lib/database/database.dart`:
|
||||
- [ ] Table indexes for query performance
|
||||
- [ ] Foreign key constraints
|
||||
- [ ] Migration strategy
|
||||
|
||||
### 10.2 Query Patterns
|
||||
- [ ] N+1 query prevention
|
||||
- [ ] Transaction usage for atomic operations
|
||||
- [ ] Connection pooling
|
||||
|
||||
### 10.3 Data Consistency
|
||||
- [ ] Tier 1 (DB) vs Tier 2 (API sync) race conditions
|
||||
- [ ] User modifications during background sync
|
||||
- [ ] Conflict resolution strategy
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
After completing the audit, provide:
|
||||
|
||||
### 1. Executive Summary
|
||||
- Overall code quality score (1-10)
|
||||
- Top 5 critical issues requiring immediate attention
|
||||
- Estimated technical debt in developer-hours
|
||||
|
||||
### 2. Issue Tracker
|
||||
Create a prioritized list of all issues found:
|
||||
| ID | Severity | Category | File:Line | Description | Effort |
|
||||
|----|----------|----------|-----------|-------------|--------|
|
||||
|
||||
### 3. Recommended Roadmap
|
||||
Sprint-based plan to address findings:
|
||||
- Sprint 1 (Immediate): Security fixes, silent exception handlers
|
||||
- Sprint 2-3: God class decomposition, test infrastructure
|
||||
- Sprint 4-6: Memory leak fixes, accessibility
|
||||
- Long-term: Performance optimization, monitoring
|
||||
|
||||
### 4. Code Examples
|
||||
For each CRITICAL and HIGH severity issue, provide:
|
||||
- Current problematic code
|
||||
- Recommended refactored code
|
||||
- Migration steps
|
||||
|
||||
---
|
||||
|
||||
## Files to Prioritize
|
||||
|
||||
Start your audit with these critical files:
|
||||
|
||||
1. `/lib/providers/music_assistant_provider.dart` (4,469 LOC) - God class
|
||||
2. `/lib/services/settings_service.dart` - Credential storage
|
||||
3. `/lib/services/music_assistant_api.dart` (2,929 LOC) - Networking
|
||||
4. `/lib/widgets/expandable_player.dart` (2,485 LOC) - UI complexity
|
||||
5. `/lib/services/auth/` directory - Security
|
||||
6. `/lib/models/player.dart` - Memory leaks
|
||||
7. `/lib/services/cache_service.dart` - Unbounded caches
|
||||
8. `/lib/services/position_tracker.dart` - Audio sync issues
|
||||
|
||||
---
|
||||
|
||||
## Known Critical Issues (Pre-Identified)
|
||||
|
||||
These issues have been identified and MUST be verified and documented:
|
||||
|
||||
1. **CRITICAL: Plaintext credential storage** - SharedPreferences without encryption
|
||||
2. **CRITICAL: Zero test coverage** - No unit, widget, or integration tests
|
||||
3. **CRITICAL: 4,469 line god class** - MusicAssistantProvider violates SRP
|
||||
4. **CRITICAL: Silent exception handlers** - `catch (e) {}` patterns
|
||||
5. **HIGH: Static memory leak** - `_playerCreationTimes` unbounded growth
|
||||
6. **HIGH: Race conditions** - Player state polling vs events
|
||||
7. **HIGH: No accessibility** - Missing Semantics, screen reader support
|
||||
8. **HIGH: Certificate validation disabled** - MITM vulnerability for local IPs
|
||||
9. **MEDIUM: Double opacity bug** - `.withOpacity().withOpacity()` in player controls
|
||||
10. **MEDIUM: Position tracker drift** - No timeout on anchor updates
|
||||
|
||||
---
|
||||
|
||||
## Audit Execution Notes
|
||||
|
||||
- Use `Grep` tool to search for patterns across the codebase
|
||||
- Use `Read` tool to examine specific file sections
|
||||
- Document line numbers for all findings
|
||||
- Provide code snippets for context
|
||||
- Cross-reference related issues across files
|
||||
- Verify pre-identified issues exist and document their exact locations
|
||||
|
||||
**Total estimated audit time: 4-6 hours for thorough coverage**
|
||||
@@ -1,419 +0,0 @@
|
||||
# Ensemble Codebase - Comprehensive Audit Report
|
||||
|
||||
**Audit Date:** 2026-01-01
|
||||
**Codebase:** Ensemble Flutter App (Music Assistant Client)
|
||||
**Version:** 2.7.3-beta+35
|
||||
**Total Lines of Code:** ~40,583 Dart
|
||||
**Files Analyzed:** 90 Dart files
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
### Overall Code Quality Score: 5.5/10
|
||||
|
||||
The Ensemble codebase demonstrates **strong architectural foundations** with a well-organized service layer, effective caching strategies, and sophisticated audio handling. However, the audit revealed **critical security vulnerabilities**, **zero test coverage**, and significant **technical debt** that must be addressed before production release.
|
||||
|
||||
### Critical Statistics
|
||||
|
||||
| Metric | Value | Status |
|
||||
|--------|-------|--------|
|
||||
| Test Coverage | 0% | CRITICAL |
|
||||
| God Class Size | 4,469 LOC | CRITICAL |
|
||||
| Silent Exception Handlers | 7 | CRITICAL |
|
||||
| Plaintext Credentials | 4 locations | CRITICAL |
|
||||
| Memory Leak Locations | 6 | HIGH |
|
||||
| Race Conditions | 4 | HIGH |
|
||||
| Accessibility (Semantics) | 0 widgets | CRITICAL |
|
||||
|
||||
---
|
||||
|
||||
## Issue Summary by Severity
|
||||
|
||||
| Severity | Count | Categories |
|
||||
|----------|-------|------------|
|
||||
| **CRITICAL** | 12 | Security, Testing, Architecture, Accessibility |
|
||||
| **HIGH** | 18 | Memory, Race Conditions, Networking, Audio |
|
||||
| **MEDIUM** | 22 | Error Handling, UI, Caching, Code Quality |
|
||||
| **LOW** | 14 | Naming, Documentation, Minor Improvements |
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL ISSUES (Immediate Action Required)
|
||||
|
||||
### 1. Plaintext Credential Storage
|
||||
**File:** `/lib/services/settings_service.dart`
|
||||
**Lines:** 131-143, 147-159, 169-190, 209-222
|
||||
|
||||
Passwords and auth tokens stored in SharedPreferences without encryption.
|
||||
|
||||
```dart
|
||||
// VULNERABLE CODE
|
||||
static Future<void> setPassword(String? password) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyPassword, password); // PLAINTEXT!
|
||||
}
|
||||
```
|
||||
|
||||
**Fix:** Use `flutter_secure_storage` for all sensitive data.
|
||||
|
||||
---
|
||||
|
||||
### 2. Zero Test Coverage
|
||||
**Location:** Entire codebase
|
||||
|
||||
No unit tests, widget tests, or integration tests exist. The `flutter_test` dependency is unused.
|
||||
|
||||
**Impact:** Cannot safely refactor, high regression risk, no confidence in correctness.
|
||||
|
||||
**Fix:** Establish test infrastructure immediately. Start with:
|
||||
- Unit tests for `MusicAssistantAPI`, `CacheService`, `ErrorHandler`
|
||||
- Widget tests for player controls
|
||||
- Integration tests for playback flows
|
||||
|
||||
---
|
||||
|
||||
### 3. God Class - MusicAssistantProvider
|
||||
**File:** `/lib/providers/music_assistant_provider.dart`
|
||||
**Size:** 4,469 lines, ~140 methods, 744 conditional branches
|
||||
|
||||
Single class handles: connection, players, library, cache, audio, sync, offline queue.
|
||||
|
||||
**Fix:** Split into:
|
||||
- `ConnectionManager` (~500 LOC)
|
||||
- `PlayerManager` (~800 LOC)
|
||||
- `LibraryCacheService` (~600 LOC)
|
||||
- `SendspinManager` (~400 LOC)
|
||||
- `MusicAssistantProvider` - Facade composing above (~800 LOC)
|
||||
|
||||
---
|
||||
|
||||
### 4. Zero Accessibility Support
|
||||
**Files:** `/lib/widgets/`, `/lib/screens/` (all)
|
||||
|
||||
- **0** `Semantics` widgets
|
||||
- **0** `semanticLabel` properties
|
||||
- **1** `Tooltip` (entire app)
|
||||
- Screen readers cannot navigate the app
|
||||
|
||||
**Fix:** Add Semantics to all interactive elements:
|
||||
```dart
|
||||
Semantics(
|
||||
button: true,
|
||||
label: isPlaying ? 'Pause playback' : 'Play',
|
||||
child: IconButton(...)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Silent Exception Handlers
|
||||
**Locations:** 7 instances across codebase
|
||||
|
||||
| File | Line | Code |
|
||||
|------|------|------|
|
||||
| player_provider.dart | 567, 575, 584 | `catch (e) {}` |
|
||||
| music_assistant_provider.dart | 2690 | `catch (e) {}` |
|
||||
| music_assistant_api.dart | 2542 | `catch (e) {}` |
|
||||
| recently_played_service.dart | 154, 206 | `catch (_) {}` |
|
||||
|
||||
**Fix:** Add logging or use `firstWhereOrNull` instead of try/catch around `firstWhere`.
|
||||
|
||||
---
|
||||
|
||||
### 6. Audio Session Listeners Not Disposed
|
||||
**File:** `/lib/services/audio/massiv_audio_handler.dart`
|
||||
**Lines:** 45-73
|
||||
|
||||
Stream subscriptions created but never cancelled - causes memory leaks and zombie listeners.
|
||||
|
||||
**Fix:** Store subscriptions and cancel in dispose():
|
||||
```dart
|
||||
StreamSubscription? _interruptionSubscription;
|
||||
_interruptionSubscription = session.interruptionEventStream.listen(...);
|
||||
|
||||
void dispose() {
|
||||
_interruptionSubscription?.cancel();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HIGH SEVERITY ISSUES
|
||||
|
||||
### 7. Connection State Subscription Not Stored
|
||||
**File:** `/lib/providers/music_assistant_provider.dart:575-614`
|
||||
|
||||
The `connectionState.listen()` return value is not stored - causes listener accumulation on reconnect.
|
||||
|
||||
---
|
||||
|
||||
### 8. Player State Race Condition
|
||||
**File:** `/lib/providers/music_assistant_provider.dart`
|
||||
|
||||
Timer polling (every 5s) and WebSocket events can race to update `_selectedPlayer` and `_currentTrack` without mutex protection.
|
||||
|
||||
---
|
||||
|
||||
### 9. Async Method Reentrancy
|
||||
**File:** `/lib/providers/music_assistant_provider.dart:2713-2841`
|
||||
|
||||
`selectPlayer()` is `void async` with no guard against concurrent calls. Multiple rapid player switches can interleave.
|
||||
|
||||
---
|
||||
|
||||
### 10. Unbounded Search Cache
|
||||
**File:** `/lib/services/cache_service.dart:31-32`
|
||||
|
||||
`_searchCache` map has no size limit or proactive TTL eviction. Grows indefinitely with user searches.
|
||||
|
||||
---
|
||||
|
||||
### 11. Static Memory Leak
|
||||
**File:** `/lib/models/player.dart:96, 139-144`
|
||||
|
||||
`_playerCreationTimes` static map cleanup only removes 50 of 100+ entries, allowing unbounded growth.
|
||||
|
||||
---
|
||||
|
||||
### 12. No Database Indexes
|
||||
**File:** `/lib/database/database.dart`
|
||||
|
||||
No secondary indexes defined. Query performance degrades as database grows.
|
||||
|
||||
---
|
||||
|
||||
### 13. No Incremental Sync
|
||||
**File:** `/lib/services/sync_service.dart:150-165`
|
||||
|
||||
Full library fetch (up to 4000 items) on every sync instead of delta updates.
|
||||
|
||||
---
|
||||
|
||||
### 14. Offline Queue Not Atomic
|
||||
**File:** `/lib/services/offline_action_queue.dart:80-108`
|
||||
|
||||
Queue persistence happens after all processing - crash during processing loses successful actions.
|
||||
|
||||
---
|
||||
|
||||
### 15. Auth Retry Without Deduplication
|
||||
**File:** `/lib/services/sendspin_service.dart:189-209`
|
||||
|
||||
Multiple auth messages can be sent during rapid reconnection attempts.
|
||||
|
||||
---
|
||||
|
||||
### 16. Pending Requests Not Completed on Disconnect
|
||||
**File:** `/lib/services/music_assistant_api.dart:2911`
|
||||
|
||||
`_pendingRequests.clear()` abandons Completers without completing them with errors.
|
||||
|
||||
---
|
||||
|
||||
### 17. HTTP Fallback for Local IPs
|
||||
**File:** `/lib/services/auth/auth_manager.dart:51-58`
|
||||
|
||||
HTTPS failures silently fall back to HTTP on local networks - MITM vulnerability.
|
||||
|
||||
---
|
||||
|
||||
### 18. Certificate Validation Bypassed
|
||||
**File:** `/lib/services/auth/auth_manager.dart:210-236`
|
||||
|
||||
HTTPS redirects explicitly refused for local IPs due to expected certificate failures.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM SEVERITY ISSUES
|
||||
|
||||
### 19. Double Opacity Bug
|
||||
**File:** `/lib/widgets/player/player_controls.dart:62-63, 99-100`
|
||||
|
||||
```dart
|
||||
// BUG: .withOpacity(0.5).withOpacity(expandedElementsOpacity) compounds opacity
|
||||
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
||||
.withOpacity(expandedElementsOpacity),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 20. Mixed Async Patterns
|
||||
**Locations:** 13 instances
|
||||
|
||||
`.then()` chains mixed with `async/await` throughout the codebase.
|
||||
|
||||
---
|
||||
|
||||
### 21. Hardcoded Colors
|
||||
**Locations:** 55 instances
|
||||
|
||||
`Color(0xFF604CEC)`, `Color(0xFF1a1a1a)` scattered across theme files and widgets.
|
||||
|
||||
---
|
||||
|
||||
### 22. View Modes as Strings
|
||||
**Locations:** 80+ instances
|
||||
|
||||
`'grid2'`, `'grid3'`, `'list'` should be enums.
|
||||
|
||||
---
|
||||
|
||||
### 23. Fire-and-Forget Pause Commands
|
||||
**File:** `/lib/providers/music_assistant_provider.dart:3875-3894`
|
||||
|
||||
Server pause command uses `unawaited()` - errors are silently lost.
|
||||
|
||||
---
|
||||
|
||||
### 24. Position Tracker Anchor Timeout Missing
|
||||
**File:** `/lib/services/position_tracker.dart:24-26`
|
||||
|
||||
No timeout on stale anchor - interpolation can drift indefinitely.
|
||||
|
||||
---
|
||||
|
||||
### 25. PCM Buffer No Overflow Protection
|
||||
**File:** `/lib/services/pcm_audio_player.dart:68, 236-247`
|
||||
|
||||
Audio buffer grows unbounded if data arrives faster than consumption.
|
||||
|
||||
---
|
||||
|
||||
### 26. No Client-Side Rate Limiting
|
||||
**Location:** App-wide
|
||||
|
||||
No throttling on API requests during rapid user interactions.
|
||||
|
||||
---
|
||||
|
||||
### 27. Portrait-Only Lock
|
||||
**File:** `/lib/main.dart:64-68`
|
||||
|
||||
No landscape orientation support.
|
||||
|
||||
---
|
||||
|
||||
### 28. Touch Targets Below 48dp
|
||||
**Files:** player_controls.dart, queue_panel.dart, player_card.dart
|
||||
|
||||
Multiple buttons at 28x28 and 44x44 instead of minimum 48x48.
|
||||
|
||||
---
|
||||
|
||||
### 29. WCAG Contrast Not Validated
|
||||
**File:** `/lib/theme/palette_helper.dart`
|
||||
|
||||
Uses 0.4 threshold instead of proper WCAG 4.5:1 contrast ratio calculation.
|
||||
|
||||
---
|
||||
|
||||
### 30. StreamControllers Not Closed
|
||||
**File:** `/lib/services/hardware_volume_service.dart:18-19, 74-85`
|
||||
|
||||
`_volumeUpController` and `_volumeDownController` never closed in dispose().
|
||||
|
||||
---
|
||||
|
||||
## LOW SEVERITY ISSUES
|
||||
|
||||
### 31-34. Naming Inconsistencies
|
||||
Mixed patterns: `_isLoading` vs `_isLoadingPlaylists`, various cache variable names.
|
||||
|
||||
### 35-38. Missing TODO Implementations
|
||||
6 actionable TODOs identified (queue removal, audiobook progress sync, playlist modification).
|
||||
|
||||
### 39-42. Dead/Unused Code
|
||||
- `@Deprecated` methods still implemented
|
||||
- `library_stats.dart` appears unused
|
||||
- `animation_debugger.dart` disabled by default
|
||||
|
||||
### 43-44. Minor Documentation Gaps
|
||||
Missing method documentation, no architecture diagram.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Remediation Roadmap
|
||||
|
||||
### Sprint 1 (Week 1-2): Security & Critical Fixes
|
||||
1. Migrate credentials to `flutter_secure_storage`
|
||||
2. Add logging to all silent catch blocks
|
||||
3. Store connection state subscription
|
||||
4. Fix audio session listener disposal
|
||||
5. Add basic test infrastructure (10% coverage target)
|
||||
|
||||
### Sprint 2 (Week 3-4): Memory & Race Conditions
|
||||
6. Add mutex to player state updates
|
||||
7. Add reentrancy guard to `selectPlayer()`
|
||||
8. Implement bounded caches with LRU eviction
|
||||
9. Fix static memory leak in Player model
|
||||
10. Store and cancel all stream subscriptions
|
||||
|
||||
### Sprint 3 (Week 5-6): Architecture Refactoring
|
||||
11. Split `MusicAssistantProvider` into 5 smaller classes
|
||||
12. Split `expandable_player.dart` into component widgets
|
||||
13. Replace GlobalKey anti-patterns with Provider
|
||||
14. Convert string constants to enums
|
||||
15. Extract hardcoded colors to constants file
|
||||
|
||||
### Sprint 4 (Week 7-8): Accessibility & UI
|
||||
16. Add Semantics to all interactive elements
|
||||
17. Increase touch targets to 48dp minimum
|
||||
18. Implement WCAG contrast validation
|
||||
19. Fix double opacity bug
|
||||
20. Add landscape orientation support
|
||||
|
||||
### Sprint 5 (Week 9-10): Performance & Polish
|
||||
21. Add database indexes
|
||||
22. Implement incremental sync
|
||||
23. Add client-side rate limiting
|
||||
24. Fix atomic offline queue processing
|
||||
25. Achieve 30% test coverage target
|
||||
|
||||
---
|
||||
|
||||
## Files Requiring Most Attention
|
||||
|
||||
| File | LOC | Issues | Priority |
|
||||
|------|-----|--------|----------|
|
||||
| `music_assistant_provider.dart` | 4,469 | 12 | CRITICAL |
|
||||
| `settings_service.dart` | 811 | 4 | CRITICAL |
|
||||
| `expandable_player.dart` | 2,485 | 6 | HIGH |
|
||||
| `music_assistant_api.dart` | 2,929 | 5 | HIGH |
|
||||
| `cache_service.dart` | 370 | 4 | HIGH |
|
||||
| `massiv_audio_handler.dart` | 296 | 2 | CRITICAL |
|
||||
| `player.dart` | 422 | 2 | HIGH |
|
||||
| `auth_manager.dart` | 300 | 3 | HIGH |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Technical Debt
|
||||
|
||||
| Category | Effort (Dev-Days) |
|
||||
|----------|-------------------|
|
||||
| Security Fixes | 3-5 |
|
||||
| Test Infrastructure | 10-15 |
|
||||
| Architecture Refactoring | 15-20 |
|
||||
| Memory Leak Fixes | 3-5 |
|
||||
| Accessibility | 5-8 |
|
||||
| UI/UX Polish | 3-5 |
|
||||
| Performance | 5-8 |
|
||||
| **Total** | **44-66 dev-days** |
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Ensemble app has a solid foundation with well-designed patterns for caching, audio handling, and multi-device synchronization. However, **critical security vulnerabilities** (plaintext credentials), **zero test coverage**, and a **4,469-line god class** represent significant risks that must be addressed before any production deployment.
|
||||
|
||||
The most urgent priorities are:
|
||||
1. **Secure credential storage** - immediate security risk
|
||||
2. **Add test infrastructure** - enables safe refactoring
|
||||
3. **Split the god class** - enables maintainability
|
||||
4. **Add accessibility** - legal/compliance requirement
|
||||
|
||||
With focused effort over 8-10 weeks, the codebase can be brought to production-quality standards.
|
||||
|
||||
---
|
||||
|
||||
*Audit conducted using 6 parallel specialized sub-agents analyzing security, memory management, race conditions, architecture, UI/UX, and audio/networking.*
|
||||
@@ -1,119 +0,0 @@
|
||||
# Ensemble Codebase Audit Prompt
|
||||
|
||||
Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app.
|
||||
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files.
|
||||
|
||||
**CRITICAL RULES:**
|
||||
1. DO NOT remove or break any existing functionality
|
||||
2. All changes must be on the current branch: `audit/code-quality-review`
|
||||
3. Run `flutter analyze` after each set of changes to verify no regressions
|
||||
4. Commit changes in logical, reviewable chunks
|
||||
|
||||
**AUDIT PHASES:**
|
||||
|
||||
### Phase 1: Code Quality Analysis (Read-Only)
|
||||
First, analyze the codebase without making changes. Create a report covering:
|
||||
|
||||
1. **Large File Analysis** - Review these files for refactoring opportunities:
|
||||
- `lib/screens/new_library_screen.dart` (122KB)
|
||||
- `lib/screens/search_screen.dart` (129KB)
|
||||
- `lib/widgets/expandable_player.dart` (107KB)
|
||||
- `lib/screens/album_details_screen.dart` (48KB)
|
||||
- `lib/screens/artist_details_screen.dart` (45KB)
|
||||
|
||||
2. **Pattern Inconsistencies** - Look for:
|
||||
- Inconsistent naming conventions (camelCase vs snake_case)
|
||||
- Mixed widget composition patterns
|
||||
- Inconsistent error handling approaches
|
||||
- Duplicate code across screens/widgets
|
||||
|
||||
3. **Performance Red Flags** - Identify:
|
||||
- Unnecessary rebuilds (missing const constructors)
|
||||
- Heavy computations in build methods
|
||||
- Missing list item caching (ListView.builder vs ListView)
|
||||
- Inefficient state management patterns
|
||||
|
||||
4. **Scroll Performance Issues** - Specifically audit:
|
||||
- ScrollController usage and disposal
|
||||
- Sliver implementations
|
||||
- AnimatedBuilder/AnimatedWidget usage
|
||||
- Image loading in scrollable lists
|
||||
- Any jank-inducing patterns
|
||||
|
||||
### Phase 2: Quick Wins (Low Risk, High Impact)
|
||||
Apply these fixes first:
|
||||
|
||||
1. Add missing `const` constructors throughout
|
||||
2. Fix any `flutter analyze` warnings
|
||||
3. Remove dead code and unused imports
|
||||
4. Standardize formatting with `dart format`
|
||||
|
||||
### Phase 3: Scroll Animation Optimization
|
||||
Focus on smoother scrolling:
|
||||
|
||||
1. **Image Optimization**
|
||||
- Ensure CachedNetworkImage is used consistently
|
||||
- Add appropriate cacheWidth/cacheHeight
|
||||
- Implement proper placeholder/error widgets
|
||||
|
||||
2. **List Performance**
|
||||
- Convert any ListView to ListView.builder where appropriate
|
||||
- Add itemExtent where possible for fixed-height items
|
||||
- Implement AutomaticKeepAliveClientMixin where needed
|
||||
- Consider RepaintBoundary for complex list items
|
||||
|
||||
3. **Animation Smoothing**
|
||||
- Use `Curves.easeOutCubic` or similar for scroll-linked animations
|
||||
- Ensure animations run at 60fps (avoid heavy computations during animation)
|
||||
- Review CustomScrollView/Sliver implementations
|
||||
|
||||
### Phase 4: Code Consistency Improvements
|
||||
|
||||
1. **Widget Extraction**
|
||||
- Extract repeated UI patterns into reusable widgets
|
||||
- Ensure extracted widgets follow existing naming patterns
|
||||
|
||||
2. **State Management**
|
||||
- Verify Provider usage is consistent
|
||||
- Check for proper disposal of controllers/streams
|
||||
|
||||
3. **Error Handling**
|
||||
- Standardize try/catch patterns
|
||||
- Ensure consistent loading/error state UI
|
||||
|
||||
### Phase 5: Verification
|
||||
|
||||
After all changes:
|
||||
1. Run `flutter analyze` - must pass with 0 issues
|
||||
2. Run `flutter test` if tests exist
|
||||
3. Create a summary of all changes made
|
||||
4. List any recommendations that were NOT implemented (too risky)
|
||||
|
||||
---
|
||||
|
||||
## Execution Instructions
|
||||
|
||||
```bash
|
||||
# Start the audit
|
||||
cd /home/home-server/Ensemble
|
||||
git checkout audit/code-quality-review
|
||||
|
||||
# After each phase, commit your changes
|
||||
git add -A && git commit -m "Audit Phase X: [description]"
|
||||
|
||||
# Verify no regressions
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Deliverables
|
||||
|
||||
1. `AUDIT_REPORT.md` - Findings from Phase 1
|
||||
2. Multiple commits with clear messages for each phase
|
||||
3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations
|
||||
@@ -1,136 +0,0 @@
|
||||
# Ensemble Flutter App - Code Audit Report
|
||||
|
||||
**Date:** January 4, 2026
|
||||
**Branch:** `audit/code-quality-review`
|
||||
**Files Audited:** 93 Dart files
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
|
||||
|
||||
| Category | Issues Found | Priority |
|
||||
|----------|-------------|----------|
|
||||
| Deprecated APIs | 20 instances | HIGH |
|
||||
| Missing const constructors | 100+ instances | MEDIUM |
|
||||
| Image cache parameters | 7 locations | MEDIUM |
|
||||
| Code duplication | 8 view mode functions | LOW |
|
||||
| Color constants | 3 files with duplicates | LOW |
|
||||
|
||||
---
|
||||
|
||||
## 1. CRITICAL: Deprecated API Usage
|
||||
|
||||
### `colorScheme.background` (20 instances)
|
||||
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
|
||||
|
||||
**Files affected:**
|
||||
- lib/main.dart:335, 336
|
||||
- lib/screens/library_artists_screen.dart:21
|
||||
- lib/screens/new_home_screen.dart:121
|
||||
- lib/screens/artist_details_screen.dart:708, 720
|
||||
- lib/screens/library_playlists_screen.dart:53
|
||||
- lib/screens/library_tracks_screen.dart:19
|
||||
- lib/screens/login_screen.dart:489
|
||||
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
|
||||
- lib/screens/album_details_screen.dart:807, 819
|
||||
- lib/screens/library_albums_screen.dart:19
|
||||
- lib/screens/search_screen.dart:427, 738, 739
|
||||
- lib/screens/settings_screen.dart:245
|
||||
|
||||
---
|
||||
|
||||
## 2. HIGH: Missing const Constructors
|
||||
|
||||
### EdgeInsets.zero (64 instances)
|
||||
High-impact optimization - used extensively in button padding.
|
||||
|
||||
**Key files:**
|
||||
- lib/screens/album_details_screen.dart (12 instances)
|
||||
- lib/screens/artist_details_screen.dart (8 instances)
|
||||
- lib/screens/audiobook_detail_screen.dart (3 instances)
|
||||
- lib/screens/audiobook_series_screen.dart (2 instances)
|
||||
- lib/screens/audiobook_author_screen.dart (2 instances)
|
||||
|
||||
### BorderRadius.circular (40+ instances)
|
||||
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
|
||||
|
||||
---
|
||||
|
||||
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
|
||||
|
||||
### CachedNetworkImage without memCacheWidth/Height
|
||||
|
||||
**lib/screens/new_library_screen.dart:**
|
||||
- Line 1395-1402: Author image (48x48) - missing cache params
|
||||
- Line 1480-1486: Author card image - missing cache params
|
||||
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
|
||||
- Line 1740-1745: Audiobook grid cover - missing cache params
|
||||
- Line 1959-1969: Playlist cover (56x56) - missing cache params
|
||||
- Line 2143-2152: Playlist grid covers - missing cache params
|
||||
- Line 2973-2981: Album cover (56x56) - missing cache params
|
||||
|
||||
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
|
||||
|
||||
---
|
||||
|
||||
## 4. LOW: Code Duplication
|
||||
|
||||
### View Mode Cycling Functions (8 nearly identical functions)
|
||||
**File:** lib/screens/new_library_screen.dart
|
||||
|
||||
Functions that follow identical switch/case pattern:
|
||||
- `_cycleArtistsViewMode()` (line 184)
|
||||
- `_cycleAlbumsViewMode()` (line 200)
|
||||
- `_cyclePlaylistsViewMode()` (line 216)
|
||||
- `_cycleAuthorsViewMode()` (line 232)
|
||||
- `_cycleAudiobooksViewMode()` (line 248)
|
||||
- `_cycleSeriesViewMode()` (line 286)
|
||||
- `_cycleRadioViewMode()` (line 303)
|
||||
- `_cyclePodcastsViewMode()` (line 319)
|
||||
|
||||
**Recommendation:** Create generic utility function.
|
||||
|
||||
### Duplicated Color Constants (3 files)
|
||||
Series fallback colors defined identically in:
|
||||
- lib/widgets/series_row.dart:246-253
|
||||
- lib/screens/audiobook_series_screen.dart:466-473
|
||||
- lib/screens/new_library_screen.dart:2112-2119
|
||||
|
||||
**Recommendation:** Extract to lib/constants/colors.dart
|
||||
|
||||
---
|
||||
|
||||
## 5. POSITIVE FINDINGS (Already Optimized)
|
||||
|
||||
- **RepaintBoundary**: Properly implemented in all list item widgets
|
||||
- **ListView.builder**: Used consistently with cacheExtent: 1000
|
||||
- **ScrollController disposal**: All 8+ controllers properly disposed
|
||||
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
|
||||
- **Debounced operations**: Color extraction debounced to 300ms
|
||||
|
||||
---
|
||||
|
||||
## Recommended Action Plan
|
||||
|
||||
### Phase 1: Quick Wins (Low Risk)
|
||||
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
|
||||
2. Add `const` to EdgeInsets.zero instances
|
||||
|
||||
### Phase 2: Image Optimization
|
||||
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
|
||||
|
||||
### Phase 3: Code Cleanup (Optional)
|
||||
1. Extract view mode cycling to utility function
|
||||
2. Centralize color constants
|
||||
|
||||
---
|
||||
|
||||
## Files by Complexity
|
||||
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
|
||||
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
|
||||
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
|
||||
@@ -1,57 +0,0 @@
|
||||
# Audit Summary
|
||||
|
||||
**Branch:** `audit/code-quality-review`
|
||||
**Date:** January 4, 2026
|
||||
|
||||
## Changes Applied
|
||||
|
||||
### Phase 1: Analysis Complete
|
||||
- Created `AUDIT_REPORT.md` with comprehensive findings
|
||||
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
|
||||
|
||||
### Phase 2: Deprecated API Fixes (18 files)
|
||||
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
|
||||
|
||||
**Files modified:**
|
||||
- lib/screens/library_artists_screen.dart
|
||||
- lib/screens/library_albums_screen.dart
|
||||
- lib/screens/library_playlists_screen.dart
|
||||
- lib/screens/library_tracks_screen.dart
|
||||
- lib/screens/artist_details_screen.dart
|
||||
- lib/screens/new_home_screen.dart
|
||||
- lib/screens/login_screen.dart
|
||||
- lib/screens/settings_screen.dart
|
||||
- lib/screens/album_details_screen.dart
|
||||
- lib/screens/search_screen.dart
|
||||
- lib/screens/new_library_screen.dart
|
||||
|
||||
### Phase 3: Scroll Performance Optimization
|
||||
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
|
||||
|
||||
**lib/screens/new_library_screen.dart:**
|
||||
- Line 1400-1401: Author list tile image (128x128 cache)
|
||||
- Line 1487-1488: Author grid card image (256x256 cache)
|
||||
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
|
||||
- Line 1749-1750: Audiobook grid cover (512x512 cache)
|
||||
- Line 1970-1971: Series list tile cover (256x256 cache)
|
||||
- Line 2986-2987: Album list tile cover (256x256 cache)
|
||||
|
||||
## Recommendations NOT Implemented (Low Priority)
|
||||
|
||||
These were identified but NOT changed to preserve functionality:
|
||||
|
||||
1. **View mode cycling refactor** - 8 similar functions could be consolidated
|
||||
2. **Color constant extraction** - Duplicated colors in 3 files
|
||||
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
|
||||
4. **BorderRadius.circular const** - 40+ instances
|
||||
|
||||
## Verification
|
||||
|
||||
Changes should be verified by:
|
||||
1. GitHub Actions build (triggered automatically)
|
||||
2. Manual UI testing of scroll smoothness
|
||||
3. Verify no visual regressions in dark/light themes
|
||||
|
||||
## Build Trigger
|
||||
|
||||
GitHub Actions workflow triggered on branch `audit/code-quality-review`
|
||||
@@ -1,6 +1,11 @@
|
||||
<div align="center">
|
||||
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
|
||||
|
||||
[](https://github.com/CollotsSpot/Ensemble/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/sponsors/CollotsSpot)
|
||||
[](https://ko-fi.com/collotsspot)
|
||||
|
||||
---
|
||||
|
||||
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
|
||||
@@ -17,6 +22,12 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
|
||||
---
|
||||
|
||||
## Also See
|
||||
|
||||
**[Ensemble TV](https://github.com/CollotsSpot/ensemble-tv)** - A simple Android TV client for Music Assistant that displays what's currently playing on your TV with remote control support.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Local Playback
|
||||
@@ -36,7 +47,8 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
### Queue Management
|
||||
- **View & Manage Queue** - See upcoming tracks in the playback queue
|
||||
- **Drag to Reorder** - Instant drag handles for reordering tracks
|
||||
- **Swipe to Delete** - Remove tracks with a simple swipe gesture
|
||||
- **Swipe left to Delete** - Remove tracks with a simple swipe gesture
|
||||
- **Swipe right to play next** - Move tracks with a simple swipe gesture
|
||||
|
||||
### Home Screen
|
||||
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
|
||||
@@ -50,7 +62,9 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
|
||||
- **Audiobooks** - Browse by title, series, or author with progress tracking
|
||||
- **Favorites Filter** - Toggle to show only your favorite items
|
||||
- **Providers Filter** - Toggle to show only specific provider items
|
||||
- **Letter Scrollbar** - Fast navigation through long lists
|
||||
- **Provider Icons** - Provider icons on library covers
|
||||
|
||||
### Search
|
||||
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
|
||||
@@ -86,16 +100,18 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
|
||||
<img src="assets/screenshots/1.png?v=4" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=4" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=4" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=4" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=4" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=4" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=4" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=4" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=4" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=4" alt="Screenshot 10" width="150">
|
||||
<img src="assets/screenshots/11.png?v=4" alt="Screenshot 11" width="150">
|
||||
<img src="assets/screenshots/12.png?v=4" alt="Screenshot 12" width="150">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
@@ -122,25 +138,11 @@ To find the correct URL:
|
||||
|
||||
If you run Music Assistant as a Home Assistant add-on:
|
||||
- Use the IP address of your Home Assistant server
|
||||
- Enter `8095` in the port field
|
||||
- Do **not** use your Home Assistant URL or ingress URL
|
||||
|
||||
### Remote Access
|
||||
|
||||
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
|
||||
|
||||
## Authentication
|
||||
|
||||
Ensemble supports multiple authentication methods:
|
||||
|
||||
| Method | Status |
|
||||
|--------|--------|
|
||||
| Music Assistant native auth | Tested |
|
||||
| No authentication | Tested |
|
||||
| Authelia | Implemented, not recently tested |
|
||||
| HTTP Basic Auth | Implemented, not recently tested |
|
||||
|
||||
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
|
||||
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Traefik, Nginx Proxy Manager, Cloudflare Tunnel).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -149,6 +151,12 @@ Ensemble supports multiple authentication methods:
|
||||
- Android device (Android 5.0+)
|
||||
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
||||
|
||||
## Sponsors
|
||||
|
||||
A huge thank you to my sponsors! 💖
|
||||
|
||||
<a href="https://github.com/pcsokonay"><img src="https://github.com/pcsokonay.png" width="60px" alt="pcsokonay" /></a>
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Release Notes Draft
|
||||
|
||||
## v2.9.2-beta (TBD)
|
||||
|
||||
### New Features
|
||||
|
||||
#### Context Menus
|
||||
- **Long-press context menus on all media items**: Albums, artists, tracks, playlists, podcasts, and radio stations now all support long-press to show a context menu with Play, Add to Library, and Favorite options.
|
||||
|
||||
#### Podcasts
|
||||
- **Podcast episode detail screen**: Tap "View Details" from episode context menu to see full scrollable description, publish date, duration, with Play, Play On, and Add to Queue actions.
|
||||
- **Episode sorting**: Sort podcast episodes by newest first, oldest first, alphabetical, or duration.
|
||||
|
||||
#### Audio Quality
|
||||
- **Hi-res audio badge**: Track listings now show a hi-res badge when high quality audio is available.
|
||||
|
||||
### Improvements
|
||||
|
||||
#### UI/UX
|
||||
- **New library icon**: Replaced confusing library icon with clearer book icon (book_2) across bottom nav, search filter, detail screens, and context menus. Filled when in library, outlined when not.
|
||||
- **Player selector**: Replaced popup dialog with player select overlay for queue actions.
|
||||
- **Search library filter**: Filled icon indicates active filter state without background highlight.
|
||||
- **Context menu polish**: Slightly larger buttons (46px), wider menu (185px), consistent text colors that don't pick up adaptive theming.
|
||||
- **Navigation transitions**: Smoother fade/slide animations between screens.
|
||||
- **Hero animations**: Podcast episode images animate smoothly to detail screen.
|
||||
|
||||
#### Performance
|
||||
- **Library list optimization**: Standardized list item heights with itemExtent, reduced image cache sizes for smoother scrolling.
|
||||
- **Palette extraction**: Moved to isolate for better UI responsiveness during color extraction.
|
||||
- **Provider selects**: Optimized provider state watching to reduce unnecessary rebuilds.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### Audiobooks
|
||||
- **Fixed audiobooks not loading after provider re-add**: When removing and re-adding an AudiobookShelf provider, the app would show 0 audiobooks because saved library paths referenced the old provider instance ID. Now automatically clears stale settings and falls back to the library_items API.
|
||||
|
||||
- **Fixed duplicate provider filter options**: After re-adding a provider, two entries for the same provider could appear in the library filter menu. Now deduplicates providers by name, keeping the one with more items.
|
||||
|
||||
- **Fixed multiple accounts of same service not showing**: When you have multiple accounts of the same service (e.g., two Spotify accounts for different family members), only one would appear in the library menu provider toggles. Now all accounts with distinct names are shown.
|
||||
|
||||
- **Fixed audiobook year sorting**: Sort by year now works correctly for audiobooks. Fixed two issues:
|
||||
- Year data is now properly extracted from ABS's `metadata.release_date` field (handles both "2024" and "2024-01-15" formats)
|
||||
- Sort handler now properly supports ascending and descending year sorts
|
||||
|
||||
#### Library Sorting
|
||||
- **Fixed letter scrollbar showing irrelevant letters**: When sorting by date or play count, the scrollbar popup now hides instead of showing meaningless letters. Shows contextual info based on sort type (letters for alphabetical, years for year sort, hidden for date/count sorts).
|
||||
|
||||
- **Fixed sorting not working for date added, last played, play count**: These sort options now correctly preserve server-side sorting instead of re-sorting alphabetically on the client. Affected albums, artists, and tracks tabs.
|
||||
|
||||
- **Fixed playlists sorting**: Modified, last played, and play count sorting now works for playlists by properly requesting sorted data from the server.
|
||||
|
||||
- **Fixed ascending/descending not working**: Sort direction now properly applies for all sort types across albums, artists, playlists, and audiobooks.
|
||||
|
||||
- **Fixed list not updating when sort order changes**: Added sort order to ListView/GridView keys to force UI rebuilds when changing sort direction.
|
||||
|
||||
#### Provider Management
|
||||
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
|
||||
|
||||
#### Playback
|
||||
- **Fixed Spotify external source detection** (issue #27): Properly detects when Spotify is the music provider.
|
||||
- **Fixed foreground service not stopping** (issue #51): Auto-stops after 30 minutes idle.
|
||||
- **Fixed Sendspin proxy auth**: Now works correctly for local network connections.
|
||||
|
||||
#### UI Fixes
|
||||
- **Fixed nav bar hidden when no player selected**: Navigation bar now always visible.
|
||||
- **Fixed Flutter 3.38 slider grey rectangles**: Volume and seek sliders render correctly.
|
||||
- **Fixed library tab scroll position**: Maintains position when switching tabs.
|
||||
|
||||
### Technical Changes
|
||||
- Flutter 3.38 compatibility
|
||||
- Simplified authentication using MA built-in auth
|
||||
- Added `material_symbols_icons` package for book_2 icon
|
||||
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
|
||||
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
|
||||
- Provider filter now validates against available providers when toggling, not just on initial load
|
||||
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
|
||||
- Options menu provider toggles now use Consumer to watch Provider state directly
|
||||
- Centralized player selector logic in GlobalPlayerOverlay.showPlayerSelectorForAction()
|
||||
@@ -29,8 +29,8 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.musicassistant.music_assistant"
|
||||
compileSdk 35
|
||||
namespace "com.collotsspot.ensemble"
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
@@ -58,8 +58,8 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
minSdk 21
|
||||
applicationId "com.collotsspot.ensemble"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
@@ -70,6 +70,11 @@ android {
|
||||
signingConfig keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
@@ -11,6 +17,7 @@
|
||||
android:label="Ensemble"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:appCategory="audio"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.collotsspot.ensemble
|
||||
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
|
||||
// while also intercepting volume button events
|
||||
class MainActivity: AudioServiceActivity() {
|
||||
private val TAG = "EnsembleVolume"
|
||||
private val CHANNEL = "com.collotsspot.ensemble/volume_buttons"
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var isListening = false
|
||||
|
||||
// Volume observer for lockscreen volume changes
|
||||
// Watches system STREAM_MUSIC volume and mirrors changes to the MA player
|
||||
// when a remote/group player is active
|
||||
private var audioManager: AudioManager? = null
|
||||
private var volumeObserver: VolumeContentObserver? = null
|
||||
private var isObservingVolume = false
|
||||
private var lastKnownVolume: Int = -1
|
||||
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
|
||||
private var ignoringVolumeChange = false
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
|
||||
|
||||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
Log.d(TAG, "Received method call: ${call.method}")
|
||||
when (call.method) {
|
||||
"startListening" -> {
|
||||
isListening = true
|
||||
Log.d(TAG, "Volume listening ENABLED")
|
||||
result.success(null)
|
||||
}
|
||||
"stopListening" -> {
|
||||
isListening = false
|
||||
Log.d(TAG, "Volume listening DISABLED")
|
||||
result.success(null)
|
||||
}
|
||||
"startVolumeObserver" -> {
|
||||
// Start observing system volume for lockscreen volume changes.
|
||||
// Optional: initialVolume (0-100) sets the system volume to match
|
||||
// the MA player's current volume for a consistent HUD display.
|
||||
val initialVolume = call.argument<Int>("initialVolume")
|
||||
startVolumeObserver(initialVolume)
|
||||
result.success(null)
|
||||
}
|
||||
"stopVolumeObserver" -> {
|
||||
stopVolumeObserver()
|
||||
result.success(null)
|
||||
}
|
||||
"syncSystemVolume" -> {
|
||||
// Sync system volume to match MA player volume (0-100)
|
||||
val volume = call.argument<Int>("volume") ?: 0
|
||||
syncSystemVolume(volume)
|
||||
result.success(null)
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Unknown method: ${call.method}")
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start observing system STREAM_MUSIC volume changes.
|
||||
/// When a change is detected, sends an "absoluteVolumeChange" event to Flutter
|
||||
/// with the new volume mapped to 0-100 scale.
|
||||
private fun startVolumeObserver(initialVolume: Int?) {
|
||||
if (isObservingVolume) return
|
||||
|
||||
val am = audioManager ?: return
|
||||
|
||||
// Optionally set system volume to match the MA player's current volume
|
||||
if (initialVolume != null) {
|
||||
syncSystemVolume(initialVolume)
|
||||
}
|
||||
|
||||
lastKnownVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
|
||||
volumeObserver = VolumeContentObserver(Handler(Looper.getMainLooper()))
|
||||
contentResolver.registerContentObserver(
|
||||
Settings.System.CONTENT_URI,
|
||||
true,
|
||||
volumeObserver!!
|
||||
)
|
||||
isObservingVolume = true
|
||||
Log.d(TAG, "Volume observer STARTED, initial system volume: $lastKnownVolume")
|
||||
}
|
||||
|
||||
/// Stop observing system volume changes.
|
||||
private fun stopVolumeObserver() {
|
||||
if (!isObservingVolume) return
|
||||
|
||||
volumeObserver?.let {
|
||||
contentResolver.unregisterContentObserver(it)
|
||||
}
|
||||
volumeObserver = null
|
||||
isObservingVolume = false
|
||||
Log.d(TAG, "Volume observer STOPPED")
|
||||
}
|
||||
|
||||
/// Set the system STREAM_MUSIC volume to match an MA player volume (0-100).
|
||||
/// Maps MA's 0-100 range to the device's 0-maxVolume range.
|
||||
private fun syncSystemVolume(maVolume: Int) {
|
||||
val am = audioManager ?: return
|
||||
val maxSystemVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val systemVolume = (maVolume * maxSystemVolume / 100).coerceIn(0, maxSystemVolume)
|
||||
|
||||
ignoringVolumeChange = true
|
||||
am.setStreamVolume(AudioManager.STREAM_MUSIC, systemVolume, 0)
|
||||
lastKnownVolume = systemVolume
|
||||
ignoringVolumeChange = false
|
||||
Log.d(TAG, "Synced system volume: MA $maVolume% -> system $systemVolume/$maxSystemVolume")
|
||||
}
|
||||
|
||||
/// ContentObserver that watches for system volume changes.
|
||||
/// When volume changes (e.g., from lockscreen hardware buttons),
|
||||
/// maps the change to a 0-100 value and sends to Flutter.
|
||||
inner class VolumeContentObserver(handler: Handler) : ContentObserver(handler) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
super.onChange(selfChange)
|
||||
|
||||
if (ignoringVolumeChange) return
|
||||
|
||||
val am = audioManager ?: return
|
||||
val currentVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
|
||||
if (currentVolume != lastKnownVolume) {
|
||||
val maxVolume = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val maVolume = if (maxVolume > 0) (currentVolume * 100 / maxVolume) else 0
|
||||
|
||||
Log.d(TAG, "Volume observer: system $lastKnownVolume -> $currentVolume (MA: $maVolume%)")
|
||||
lastKnownVolume = currentVolume
|
||||
|
||||
// Send the absolute volume (0-100) to Flutter
|
||||
methodChannel?.invokeMethod("absoluteVolumeChange", maVolume)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
|
||||
// and may consume events before they reach onKeyDown
|
||||
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
|
||||
if (event == null) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
val keyCode = event.keyCode
|
||||
val action = event.action
|
||||
|
||||
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
|
||||
|
||||
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
|
||||
if (action != KeyEvent.ACTION_DOWN) {
|
||||
// For volume keys when listening, also consume ACTION_UP to fully block system volume
|
||||
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
|
||||
Log.d(TAG, "Consuming ACTION_UP for volume key")
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
if (!isListening) {
|
||||
Log.d(TAG, "Not listening, passing to super")
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
|
||||
methodChannel?.invokeMethod("volumeUp", null)
|
||||
true // Consume the event to prevent system volume change
|
||||
}
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
|
||||
methodChannel?.invokeMethod("volumeDown", null)
|
||||
true // Consume the event to prevent system volume change
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Other key, passing to super")
|
||||
super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pause volume interception when app goes to background so hardware buttons
|
||||
// only control the system volume (e.g., YouTube, phone ringer) as expected.
|
||||
private var wasListeningBeforePause = false
|
||||
private var wasObservingBeforePause = false
|
||||
|
||||
override fun onPause() {
|
||||
wasListeningBeforePause = isListening
|
||||
wasObservingBeforePause = isObservingVolume
|
||||
isListening = false
|
||||
stopVolumeObserver()
|
||||
Log.d(TAG, "onPause: suspended volume interception (wasListening=$wasListeningBeforePause, wasObserving=$wasObservingBeforePause)")
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (wasListeningBeforePause) {
|
||||
isListening = true
|
||||
}
|
||||
if (wasObservingBeforePause) {
|
||||
startVolumeObserver(null)
|
||||
}
|
||||
Log.d(TAG, "onResume: restored volume interception (listening=$isListening, observing=$isObservingVolume)")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopVolumeObserver()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package com.musicassistant.music_assistant
|
||||
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
|
||||
// while also intercepting volume button events
|
||||
class MainActivity: AudioServiceActivity() {
|
||||
private val TAG = "EnsembleVolume"
|
||||
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var isListening = false
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
|
||||
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
Log.d(TAG, "Received method call: ${call.method}")
|
||||
when (call.method) {
|
||||
"startListening" -> {
|
||||
isListening = true
|
||||
Log.d(TAG, "Volume listening ENABLED")
|
||||
result.success(null)
|
||||
}
|
||||
"stopListening" -> {
|
||||
isListening = false
|
||||
Log.d(TAG, "Volume listening DISABLED")
|
||||
result.success(null)
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Unknown method: ${call.method}")
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
|
||||
// and may consume events before they reach onKeyDown
|
||||
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
|
||||
if (event == null) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
val keyCode = event.keyCode
|
||||
val action = event.action
|
||||
|
||||
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
|
||||
|
||||
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
|
||||
if (action != KeyEvent.ACTION_DOWN) {
|
||||
// For volume keys when listening, also consume ACTION_UP to fully block system volume
|
||||
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
|
||||
Log.d(TAG, "Consuming ACTION_UP for volume key")
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
if (!isListening) {
|
||||
Log.d(TAG, "Not listening, passing to super")
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
|
||||
methodChannel?.invokeMethod("volumeUp", null)
|
||||
true // Consume the event to prevent system volume change
|
||||
}
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
|
||||
methodChannel?.invokeMethod("volumeDown", null)
|
||||
true // Consume the event to prevent system volume change
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Other key, passing to super")
|
||||
super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launch_background_color">#1a1a1a</color>
|
||||
</resources>
|
||||
<color name="ic_launcher_background">#1a1a1a</color>
|
||||
</resources>
|
||||
@@ -1,15 +1,12 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.10'
|
||||
ext.kotlin_version = '2.1.0'
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
classpath 'com.android.tools.build:gradle:8.9.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
@@ -17,9 +14,6 @@ buildscript {
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
@@ -22,8 +22,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.1.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
|
||||
id "com.android.application" version "8.9.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 909 KiB |
@@ -0,0 +1,336 @@
|
||||
# Per-Provider Sync Implementation
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This document details the implementation of per-provider sync with source tracking, enabling instant client-side filtering that properly differentiates between multiple accounts of the same provider type (e.g., two different Spotify accounts).
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### The Core Issue
|
||||
|
||||
When a user has multiple accounts of the same provider type (e.g., two Spotify accounts: "Chris Laycock" and "Bababagoly"), the existing provider filter couldn't differentiate between them.
|
||||
|
||||
**Why?** Music Assistant's `provider_mappings` field on media items shows which providers **CAN PLAY** an item, not which provider **ADDED** it. When both Spotify accounts can play the same artist, both appear in `provider_mappings`, making client-side filtering impossible.
|
||||
|
||||
### Previous Approach (Failed)
|
||||
|
||||
The initial implementation tried to filter client-side using `provider_mappings`:
|
||||
|
||||
```dart
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
for (final mapping in mappings) {
|
||||
if (_providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Both Spotify accounts showed the same items because `provider_mappings` contained both account IDs for shared content.
|
||||
|
||||
### Race Condition Issue
|
||||
|
||||
Additionally, when rapidly toggling providers, a race condition occurred:
|
||||
1. User toggles Provider A off
|
||||
2. Sync starts for remaining providers
|
||||
3. User toggles Provider B off before sync completes
|
||||
4. First sync completes with wrong filter state
|
||||
5. UI shows incorrect results
|
||||
|
||||
## Solution: Per-Provider Sync with Source Tracking
|
||||
|
||||
### Concept
|
||||
|
||||
Instead of relying on `provider_mappings`, we:
|
||||
1. **Sync each provider separately** using the MA API's `provider` parameter
|
||||
2. **Tag each item** with which provider instance(s) returned it
|
||||
3. **Store source tracking** in the local database
|
||||
4. **Filter client-side** using the source tracking data (instant)
|
||||
5. **Background sync** keeps data fresh without blocking UI
|
||||
|
||||
### Why This Works
|
||||
|
||||
When we call the MA API with `provider: ["spotify--RKnr3jKx"]`, the server returns **only items from that specific account's library**. By syncing each provider separately and remembering which provider returned each item, we can accurately filter.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Schema Update
|
||||
|
||||
**File:** `lib/database/database.dart`
|
||||
|
||||
Added `sourceProviders` column to `LibraryCache` table:
|
||||
|
||||
```dart
|
||||
class LibraryCache extends Table {
|
||||
// ... existing columns ...
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
}
|
||||
```
|
||||
|
||||
**Schema version:** 5 → 6
|
||||
|
||||
**Migration:**
|
||||
```dart
|
||||
if (from < 6) {
|
||||
await customStatement(
|
||||
"ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Service Updates
|
||||
|
||||
**File:** `lib/services/database_service.dart`
|
||||
|
||||
Added `sourceProvider` parameter to `cacheItem()`:
|
||||
|
||||
```dart
|
||||
Future<void> cacheItem<T>({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required Map<String, dynamic> data,
|
||||
String? sourceProvider, // NEW: tracks which provider this item came from
|
||||
})
|
||||
```
|
||||
|
||||
Added new method to retrieve items with source tracking:
|
||||
|
||||
```dart
|
||||
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType)
|
||||
```
|
||||
|
||||
Returns tuples of `(itemData, sourceProviderIds)`.
|
||||
|
||||
### 3. SyncService Rewrite
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
#### New Fields
|
||||
|
||||
```dart
|
||||
// Source provider tracking for client-side filtering
|
||||
Map<String, List<String>> _albumSourceProviders = {};
|
||||
Map<String, List<String>> _artistSourceProviders = {};
|
||||
Map<String, List<String>> _audiobookSourceProviders = {};
|
||||
Map<String, List<String>> _playlistSourceProviders = {};
|
||||
```
|
||||
|
||||
#### Per-Provider Sync Logic
|
||||
|
||||
```dart
|
||||
Future<void> syncFromApi(MusicAssistantAPI api, {
|
||||
bool force = false,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
// If specific providers are requested, sync each separately
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
for (final providerId in providerInstanceIds) {
|
||||
// Fetch from this specific provider
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
|
||||
api.getArtists(limit: 1000, providerInstanceIds: [providerId]),
|
||||
// ... etc
|
||||
]);
|
||||
|
||||
// Track source provider for each item
|
||||
for (final album in albums) {
|
||||
albumMap[album.itemId] = album;
|
||||
_albumSourceProviders.putIfAbsent(album.itemId, () => []);
|
||||
if (!_albumSourceProviders[album.itemId]!.contains(providerId)) {
|
||||
_albumSourceProviders[album.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Client-Side Filtering Methods
|
||||
|
||||
```dart
|
||||
/// Filter albums by source provider (instant, no network)
|
||||
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty || _albumSourceProviders.isEmpty) {
|
||||
return _cachedAlbums;
|
||||
}
|
||||
return _cachedAlbums.where((album) {
|
||||
final sources = _albumSourceProviders[album.itemId];
|
||||
if (sources == null || sources.isEmpty) return true; // No tracking = show
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Similar methods for artists, audiobooks, playlists
|
||||
```
|
||||
|
||||
### 4. Library Screen Updates
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
#### Hybrid Provider Toggle Handler
|
||||
|
||||
```dart
|
||||
void _handleProviderToggle(String providerId, bool enabled) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Instant UI update via global provider toggle
|
||||
maProvider.toggleProviderEnabled(providerId, enabled);
|
||||
setState(() {}); // Rebuild with client-side filtering
|
||||
|
||||
// Background sync after 1.5s debounce
|
||||
_providerFilterDebounce?.cancel();
|
||||
_providerFilterDebounce = Timer(const Duration(milliseconds: 1500), () async {
|
||||
if (mounted) {
|
||||
await maProvider.forceLibrarySync();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Updated Tab Builders
|
||||
|
||||
Artists tab now uses source tracking:
|
||||
|
||||
```dart
|
||||
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
||||
return Selector<MusicAssistantProvider, (List<Artist>, bool, Set<String>)>(
|
||||
selector: (_, provider) => (
|
||||
provider.artists,
|
||||
provider.isLoading,
|
||||
provider.enabledProviderIds.toSet()
|
||||
),
|
||||
builder: (context, data, _) {
|
||||
final (allArtists, isLoading, enabledProviders) = data;
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
final syncService = SyncService.instance;
|
||||
final filteredArtists = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getArtistsFilteredByProviders(enabledProviders)
|
||||
: allArtists;
|
||||
|
||||
// ... rest of build
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Similar updates for albums, playlists, and audiobooks tabs.
|
||||
|
||||
### 5. Provider Instance Model
|
||||
|
||||
**File:** `lib/models/provider_instance.dart` (new file)
|
||||
|
||||
```dart
|
||||
class ProviderInstance {
|
||||
final String instanceId; // e.g., "spotify--abc123"
|
||||
final String domain; // e.g., "spotify"
|
||||
final String name; // e.g., "Spotify (John's Account)"
|
||||
final bool available;
|
||||
|
||||
/// Known music provider domains
|
||||
static const Set<String> musicProviderDomains = {
|
||||
'spotify', 'tidal', 'qobuz', 'deezer', 'ytmusic', 'soundcloud',
|
||||
'apple_music', 'amazon_music', 'plex', 'subsonic', 'opensubsonic',
|
||||
'jellyfin', 'emby', 'navidrome', 'audiobookshelf', 'filesystem',
|
||||
'tunein', 'radiobrowser', 'snapcast', 'fully_kiosk',
|
||||
};
|
||||
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 1. MusicAssistantProvider.toggleProviderEnabled() │
|
||||
│ - Updates enabledProviderIds │
|
||||
│ - Triggers setState() for instant UI update │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 2. UI Rebuilds Immediately │
|
||||
│ - Selector detects enabledProviderIds change │
|
||||
│ - Calls SyncService.getArtistsFilteredByProviders() │
|
||||
│ - Filters using source tracking (instant, no network) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 3. Background Sync (after 1.5s debounce) │
|
||||
│ - forceLibrarySync() called │
|
||||
│ - Syncs each enabled provider separately │
|
||||
│ - Updates source tracking in database │
|
||||
│ - UI refreshes with fresh data │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/database/database.dart` | Added `sourceProviders` column, schema v6, migration |
|
||||
| `lib/database/database.g.dart` | Auto-generated from schema changes |
|
||||
| `lib/services/database_service.dart` | Added `sourceProvider` param, `getCachedItemsWithProviders()` |
|
||||
| `lib/services/sync_service.dart` | Per-provider sync, source tracking maps, filtering methods |
|
||||
| `lib/screens/new_library_screen.dart` | Hybrid toggle handler, updated tab builders |
|
||||
| `lib/models/provider_instance.dart` | New model for provider instances |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Fresh install: First sync should populate source tracking
|
||||
- [ ] Toggle single provider: UI updates instantly
|
||||
- [ ] Toggle multiple providers rapidly: Debounce prevents race conditions
|
||||
- [ ] Two Spotify accounts: Toggling one only shows that account's items
|
||||
- [ ] Mixed providers: Toggling Spotify doesn't affect Audiobookshelf items
|
||||
- [ ] Legacy cache: Items without source tracking show regardless of filter
|
||||
- [ ] Pull to refresh: Refreshes source tracking data
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **First sync required:** Source tracking only works after at least one sync with providers enabled
|
||||
2. **Network cost:** Per-provider sync makes N API calls (one per provider) vs. 1 call for all providers
|
||||
3. **Items in multiple accounts:** If the same item exists in both Spotify accounts, it will show when either is enabled
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Incremental sync:** Only sync changed items instead of full library
|
||||
2. **Provider-specific cache age:** Different sync intervals per provider
|
||||
3. **Offline source tracking:** Persist source tracking independently of full cache
|
||||
|
||||
## Branch
|
||||
|
||||
All changes are on the `feature/per-provider-sync` branch.
|
||||
|
||||
```bash
|
||||
git checkout feature/per-provider-sync
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat: Add per-provider sync with source tracking for instant filtering
|
||||
|
||||
- Add sourceProviders column to database schema (v6) for tracking which
|
||||
provider instance each item came from
|
||||
- Update SyncService to sync each enabled provider separately and tag
|
||||
items with their source provider
|
||||
- Add client-side filtering methods that use source tracking to properly
|
||||
differentiate between multiple accounts of the same provider type
|
||||
- Update library UI to use instant client-side filtering with background
|
||||
sync for data freshness
|
||||
```
|
||||
@@ -0,0 +1,217 @@
|
||||
# Provider Filter Implementation Notes
|
||||
|
||||
## Overview
|
||||
|
||||
This document captures the context and learnings from an attempted implementation of provider filtering in the Ensemble app. The goal was to allow users to filter their library to show only items from a specific music provider (e.g., only one Spotify account when multiple are configured).
|
||||
|
||||
## The Problem
|
||||
|
||||
Users with multiple music provider accounts (e.g., two Spotify accounts: one personal, one family) want to filter their library view to show only items from one specific account. The Music Assistant (MA) web UI successfully implements this feature.
|
||||
|
||||
## What Works vs What Failed
|
||||
|
||||
### Works: Different Provider Types
|
||||
Filtering between different provider types (e.g., Spotify vs Tidal vs Plex) works correctly. Each provider type has distinct `provider_mappings` entries.
|
||||
|
||||
### Failed: Multiple Accounts of Same Provider
|
||||
Filtering between multiple Spotify accounts (e.g., `spotify--abc123` vs `spotify--xyz789`) does NOT work correctly. The same library items appear regardless of which Spotify account is selected.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### The provider_mappings Problem
|
||||
|
||||
MA's `provider_mappings` field on media items does NOT indicate "which account ADDED this item to the library." Instead, it indicates "which providers CAN PLAY this item."
|
||||
|
||||
Example: An artist "Ari Mason" that exists ONLY in User A's Spotify library will show provider_mappings containing BOTH Spotify accounts:
|
||||
```json
|
||||
{
|
||||
"provider_mappings": [
|
||||
{"provider_instance": "spotify--userA_id", "item_id": "..."},
|
||||
{"provider_instance": "spotify--userB_id", "item_id": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This happens because:
|
||||
1. MA's internal database stores that the artist exists
|
||||
2. MA knows both Spotify accounts CAN play this artist (Spotify has the content)
|
||||
3. MA doesn't track WHICH account originally added the item to the library
|
||||
|
||||
### MA Web UI Behavior
|
||||
|
||||
The MA web UI's provider filter works differently - it appears to use server-side filtering via the `provider` parameter on library_items API calls. When you select a single provider in MA settings, the API returns only items from that provider's library.
|
||||
|
||||
## API Investigation
|
||||
|
||||
### Server-Side Filtering Parameter
|
||||
|
||||
The MA server's `library_items` method accepts a `provider` parameter:
|
||||
- Parameter name: `provider` (string or list of strings)
|
||||
- Endpoint: `music/{type}/library_items` (e.g., `music/artists/library_items`)
|
||||
- Expected behavior: Filter to only return items from the specified provider's library
|
||||
|
||||
### What We Tried
|
||||
|
||||
1. **Client-side filtering based on provider_mappings**
|
||||
- Result: Failed - all Spotify accounts appear in mappings regardless of which added the item
|
||||
|
||||
2. **Server-side filtering with `provider` parameter**
|
||||
- Result: Partially implemented but behavior was inconsistent
|
||||
- The API accepts the parameter but may not filter correctly for same-provider-type accounts
|
||||
|
||||
3. **Using `provider_instance_id_or_domain` parameter**
|
||||
- Result: This parameter is for getting specific items by ID, not for filtering library_items
|
||||
|
||||
## Code Locations
|
||||
|
||||
### Files Modified (in stash)
|
||||
|
||||
The implementation attempt is preserved in git stash:
|
||||
```bash
|
||||
git stash list # Look for "provider-filter-and-dropdown-work"
|
||||
git stash show -p stash@{0} # View the changes
|
||||
```
|
||||
|
||||
Key files that were modified:
|
||||
|
||||
1. **lib/providers/music_assistant_provider.dart**
|
||||
- `_disabledProviders`: List of disabled provider instance IDs (local app setting)
|
||||
- `selectedProviderForFilter`: Returns single enabled provider for server-side filtering
|
||||
- `toggleProviderEnabled()`: Toggle provider on/off and trigger re-sync
|
||||
- `loadDisabledProviders()`: Load from local settings
|
||||
- `filterByProvider()`: Client-side filtering (enhanced version)
|
||||
- `isItemAllowedByProviderFilter()`: Check if item should be visible
|
||||
|
||||
2. **lib/services/music_assistant_api.dart**
|
||||
- Added `provider` parameter to: `getArtists`, `getAlbums`, `getTracks`, `getRandomArtists`, `getRandomAlbums`
|
||||
- Added `MusicProvider` model parsing from `/providers` endpoint
|
||||
- `musicProviders` getter: List of available music providers
|
||||
|
||||
3. **lib/services/sync_service.dart**
|
||||
- Added `provider` parameter to `syncFromApi()` and `forceSync()`
|
||||
- Pass provider filter to API calls during sync
|
||||
|
||||
4. **lib/services/settings_service.dart**
|
||||
- `getDisabledMusicProviders()`: Get list of disabled provider IDs
|
||||
- `setDisabledMusicProviders()`: Save disabled providers
|
||||
- `toggleProviderEnabled()`: Toggle and persist
|
||||
|
||||
5. **lib/screens/settings_screen.dart**
|
||||
- UI for toggling individual providers on/off
|
||||
- Display provider name and instance ID
|
||||
|
||||
6. **lib/screens/new_library_screen.dart**
|
||||
- Integration with provider filtering
|
||||
|
||||
7. **lib/models/music_provider.dart** (new file)
|
||||
- Model for MA provider instances
|
||||
- Fields: `instanceId`, `domain`, `name`, `available`
|
||||
|
||||
## Race Condition Issue
|
||||
|
||||
A race condition was identified where:
|
||||
1. Initial app sync starts WITHOUT provider filter
|
||||
2. User toggles provider filter
|
||||
3. `clearCache()` is called
|
||||
4. New sync with provider filter tries to start
|
||||
5. "Sync already in progress" - new sync is skipped
|
||||
6. Result: Old unfiltered data remains
|
||||
|
||||
Fix attempted: Reset `_isSyncing = false` in `clearCache()` to allow fresh sync.
|
||||
|
||||
## Potential Solutions to Explore
|
||||
|
||||
### 1. Investigate MA Server-Side Filtering
|
||||
|
||||
The MA server should support filtering by provider. Need to verify:
|
||||
- Does the `provider` parameter work for filtering between same-type providers?
|
||||
- Is there a different parameter or API endpoint?
|
||||
- Check MA source code: https://github.com/music-assistant/server
|
||||
|
||||
### 2. Library Sync Per Provider
|
||||
|
||||
Instead of syncing all items and filtering, sync each provider's library separately:
|
||||
```dart
|
||||
for (final provider in enabledProviders) {
|
||||
final items = await api.getAlbums(provider: provider.instanceId);
|
||||
// Store with provider association
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Track Item Origin
|
||||
|
||||
Store which provider originally added each item:
|
||||
- Modify database schema to track `source_provider`
|
||||
- When syncing, record which provider the item came from
|
||||
- Filter based on source, not playability
|
||||
|
||||
### 4. Use MA's Internal Library State
|
||||
|
||||
MA might have internal state about which items are in which provider's library. Investigate:
|
||||
- `/music/library/items` endpoint
|
||||
- Provider-specific library endpoints
|
||||
- MA's database schema for library associations
|
||||
|
||||
### 5. Check MA User Settings
|
||||
|
||||
The MA web UI's provider filter might be stored in user settings:
|
||||
```dart
|
||||
final userInfo = await api.getCurrentUserInfo();
|
||||
// Check for provider_filter or similar field
|
||||
```
|
||||
|
||||
## Testing Notes
|
||||
|
||||
### Log Messages to Watch
|
||||
|
||||
```
|
||||
🔌 Selected provider for filter: spotify--xxxxx
|
||||
🔍 API getArtists args: {limit: 1000, provider: spotify--xxxxx}
|
||||
🔍 API getArtists returned XXX items
|
||||
🔌 filterByProvider: XXX → YYY (disabled=[...])
|
||||
```
|
||||
|
||||
### Test Scenario
|
||||
|
||||
1. Configure two Spotify accounts in MA
|
||||
2. Add different artists to each account's library
|
||||
3. In Ensemble, select only one Spotify account
|
||||
4. Verify library shows ONLY that account's items
|
||||
5. Switch to other account, verify different items
|
||||
|
||||
### Expected vs Actual
|
||||
|
||||
- **Expected**: Artist "Ari Mason" (only in Account A) should NOT appear when Account B is selected
|
||||
- **Actual**: Artist appears regardless of which account is selected
|
||||
|
||||
## MA API Reference
|
||||
|
||||
### Get Providers
|
||||
```
|
||||
Command: providers
|
||||
Returns: List of provider instances with type, domain, instanceId, name, available
|
||||
```
|
||||
|
||||
### Library Items with Filter
|
||||
```
|
||||
Command: music/artists/library_items
|
||||
Args: {
|
||||
limit: int,
|
||||
offset: int,
|
||||
provider: string | list<string>, // Filter to specific provider(s)
|
||||
album_artists_only: bool,
|
||||
order_by: string
|
||||
}
|
||||
```
|
||||
|
||||
### Relevant MA Source Files
|
||||
|
||||
- `music_assistant/server/controllers/music.py` - Library item retrieval
|
||||
- `music_assistant/server/models/provider.py` - Provider model
|
||||
- `music_assistant/server/providers/` - Individual provider implementations
|
||||
|
||||
## Conclusion
|
||||
|
||||
The core challenge is that MA's `provider_mappings` indicates playability, not ownership. Server-side filtering via the `provider` parameter should work but needs further investigation to understand why it doesn't differentiate between multiple accounts of the same provider type.
|
||||
|
||||
The implementation is preserved in the git stash for future reference. The sorting/dropdown improvements were extracted and committed separately.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Audiobook Provider Filtering Fix
|
||||
|
||||
## Date: January 17, 2026 (Continuation)
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering issues discovered after the initial provider filtering improvements:
|
||||
1. Audiobooks were not responding to provider filter changes (browse API ignoring `providerInstanceIds`)
|
||||
2. UI not updating instantly when toggling providers (missing `notifyListeners()` call)
|
||||
3. Audiobook tabs not rebuilding on provider changes (using `context.read` instead of `Selector`)
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
After implementing capability-based provider filtering for the music library, two issues remained:
|
||||
|
||||
### Issue 1: Audiobooks Not Filtering During Sync
|
||||
|
||||
The bug report showed:
|
||||
```
|
||||
📊 Source tracking: 252 albums, 87 artists have provider info
|
||||
📚 Total audiobooks from browse: 186
|
||||
```
|
||||
|
||||
- Albums and artists had provider source tracking
|
||||
- Audiobooks had NO provider source tracking (not listed)
|
||||
- All 186 audiobooks were returned regardless of provider selection
|
||||
|
||||
### Issue 2: UI Not Updating Instantly
|
||||
|
||||
When toggling providers in the library menu:
|
||||
- Music tabs weren't updating immediately
|
||||
- Audiobooks required manual pull-to-refresh
|
||||
- Changes only appeared after ~600ms (debounce timer)
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Cause 1: Browse API Ignoring Provider Filter
|
||||
|
||||
In `lib/services/music_assistant_api.dart`, when ABS library filtering is enabled, the `getAudiobooks()` function uses the browse API but was **ignoring** the `providerInstanceIds` parameter:
|
||||
|
||||
```dart
|
||||
// BEFORE: providerInstanceIds was ignored when using browse API
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Cause 2: `notifyListeners()` Called Too Late
|
||||
|
||||
In `lib/providers/music_assistant_provider.dart`, the `toggleProviderEnabled()` function only called `notifyListeners()` **inside** the 600ms debounce timer:
|
||||
|
||||
```dart
|
||||
// BEFORE: notifyListeners only called after 600ms debounce
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
forceLibrarySync();
|
||||
notifyListeners(); // Too late! UI should update immediately
|
||||
});
|
||||
```
|
||||
|
||||
### Cause 3: Audiobook Tabs Not Listening for Changes
|
||||
|
||||
The audiobook tabs used `context.read<MusicAssistantProvider>()` which doesn't trigger rebuilds when the provider changes:
|
||||
|
||||
```dart
|
||||
// BEFORE: context.read doesn't listen for changes
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
// ... widget doesn't rebuild when enabledProviders changes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solutions
|
||||
|
||||
### Fix 1: Browse API Provider Filtering
|
||||
|
||||
**File:** `lib/services/music_assistant_api.dart` (lines 760-785)
|
||||
|
||||
Added logic to filter library paths by provider ID before browsing:
|
||||
|
||||
```dart
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
_logger.log('📚 getAudiobooks: enabledLibraries=$enabledLibraries, providerInstanceIds=$providerInstanceIds');
|
||||
|
||||
// If specific providers are requested, filter libraries to only matching providers
|
||||
// Library paths start with provider ID (e.g., "audiobookshelf--abc123://lb...")
|
||||
var librariesToBrowse = enabledLibraries;
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
librariesToBrowse = enabledLibraries.where((libPath) {
|
||||
// Extract provider ID from library path (everything before "://")
|
||||
final providerEnd = libPath.indexOf('://');
|
||||
if (providerEnd == -1) return false;
|
||||
final providerId = libPath.substring(0, providerEnd);
|
||||
return providerInstanceIds.contains(providerId);
|
||||
}).toList();
|
||||
|
||||
// If no libraries match the requested providers, return empty
|
||||
if (librariesToBrowse.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 2: Immediate `notifyListeners()` Call
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart` (line 1149)
|
||||
|
||||
Moved `notifyListeners()` to fire immediately after `_enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
// Reload the enabled providers
|
||||
final savedEnabled = await SettingsService.getEnabledMusicProviders();
|
||||
_enabledProviderIds = savedEnabled ?? [];
|
||||
|
||||
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
|
||||
// This enables instant UI updates using cached data with source tracking
|
||||
notifyListeners();
|
||||
|
||||
// Cancel any pending debounce timer
|
||||
_providerFilterDebounceTimer?.cancel();
|
||||
|
||||
// Start debounced sync - waits 600ms for user to finish toggling
|
||||
// This refreshes the cache with new data from enabled providers
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
_logger.log('🔄 Debounce complete, starting library sync...');
|
||||
forceLibrarySync();
|
||||
});
|
||||
```
|
||||
|
||||
### Fix 3: Audiobook Tabs Use Selector
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Wrapped `_buildBooksAuthorsTab` and `_buildAllBooksTab` in `Selector` to rebuild when `enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
// Use Selector to rebuild when enabledProviderIds changes
|
||||
return Selector<MusicAssistantProvider, Set<String>>(
|
||||
selector: (_, provider) => provider.enabledProviderIds.toSet(),
|
||||
builder: (context, enabledProviders, _) {
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
// Client-side filtering using SyncService source tracking for instant updates
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// ... rest of widget
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 4: Enhanced Sync Logging
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
Added audiobook counts to logging:
|
||||
|
||||
```dart
|
||||
// Per-provider log (line 237)
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||
|
||||
// Source tracking summary (line 323)
|
||||
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow After Fixes
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
│ │
|
||||
│ 1. toggleProviderEnabled() called │
|
||||
│ 2. _enabledProviderIds updated │
|
||||
│ 3. notifyListeners() called IMMEDIATELY │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Selectors Detect Change │
|
||||
│ │
|
||||
│ Music tabs: Selector<..., (List, bool, Set<String>)> │
|
||||
│ Audiobook tabs: Selector<..., Set<String>> │
|
||||
│ │
|
||||
│ → All tabs rebuild immediately │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Client-Side Filtering (Instant) │
|
||||
│ │
|
||||
│ syncService.getAlbumsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getArtistsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getAudiobooksFilteredByProviders(enabledProviders)│
|
||||
│ │
|
||||
│ → Uses EXISTING cache with source tracking │
|
||||
│ → Instant UI update, no network required │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Background Sync (After 600ms Debounce) │
|
||||
│ │
|
||||
│ - Refreshes cache with new data from enabled providers │
|
||||
│ - Updates source tracking │
|
||||
│ - Only needed when ENABLING providers (to fetch new data) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/services/music_assistant_api.dart` | Added provider filtering to browse API path in `getAudiobooks()`, added debug logging |
|
||||
| `lib/services/sync_service.dart` | Added audiobook counts to per-provider and summary logs |
|
||||
| `lib/providers/music_assistant_provider.dart` | Moved `notifyListeners()` to fire immediately on provider toggle |
|
||||
| `lib/screens/new_library_screen.dart` | Wrapped audiobook tabs in `Selector` to rebuild on provider changes |
|
||||
|
||||
---
|
||||
|
||||
## Expected Behavior After Fixes
|
||||
|
||||
1. **Instant UI Updates:** When you toggle a provider, the library immediately updates using client-side filtering
|
||||
2. **Audiobook Source Tracking:** Audiobooks now have provider source tracking (visible in logs)
|
||||
3. **Audiobook Tab Rebuilds:** Both audiobook tabs (Authors and All Books) rebuild instantly on provider toggle
|
||||
4. **Music Tab Consistency:** Music tabs continue to work as before, now with immediate rebuilds
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Toggle a music provider, verify instant UI update (no 600ms delay)
|
||||
- [ ] Toggle between different Spotify accounts, verify correct filtering
|
||||
- [ ] Sync with ABS library filtering enabled
|
||||
- [ ] Verify audiobooks appear in source tracking log
|
||||
- [ ] Toggle ABS provider off in library menu
|
||||
- [ ] Verify audiobooks list becomes empty immediately (no pull-to-refresh needed)
|
||||
- [ ] Toggle ABS provider back on
|
||||
- [ ] Verify audiobooks reappear after sync completes
|
||||
- [ ] Test with multiple providers (Spotify audiobooks + ABS)
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/SESSION_2026_01_17_PROVIDER_FILTERING.md` - Initial provider filtering implementation
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Per-provider sync architecture
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab still doesn't filter by provider. Series are fetched as `AudiobookSeries` objects without per-provider tracking.
|
||||
|
||||
2. **Mixed Provider Scenario:** If user has both Spotify audiobooks and ABS audiobooks, and ABS library filtering is enabled, Spotify audiobooks may not be fetched because the browse API path is taken. This is a pre-existing architectural limitation.
|
||||
|
||||
3. **Enabling Providers:** When ENABLING a previously disabled provider, the UI updates immediately but shows empty until the background sync completes (because the cache doesn't have that provider's data yet). This is expected behavior.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Session 2026-01-17: Optimistic Updates & Podcast Provider Filtering
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering for podcasts, resolving iTunes podcast provider issues, and implementing optimistic updates for library buttons across detail screens.
|
||||
|
||||
## Issues Addressed
|
||||
|
||||
### 1. Podcast Provider Counts Showing Incorrectly
|
||||
|
||||
**Problem**: All Spotify accounts showed the same podcast count instead of only counting podcasts owned by each account.
|
||||
|
||||
**Root Cause**: The podcast counting logic wasn't checking the `inLibrary` field on provider mappings.
|
||||
|
||||
**Solution**:
|
||||
- Added `inLibrary` field to `ProviderMapping` model
|
||||
- Updated `getProvidersWithPodcasts()` to only count mappings where `inLibrary == true`
|
||||
- Updated podcast filtering in library screen to check `inLibrary` field
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/media_item.dart` - Added `inLibrary` field
|
||||
- `lib/providers/music_assistant_provider.dart` - Updated counting logic
|
||||
- `lib/screens/new_library_screen.dart` - Updated filtering logic
|
||||
|
||||
### 2. iTunes Podcasts Not Appearing in Library
|
||||
|
||||
**Problem**: Podcasts added from iTunes search weren't showing in library and iTunes provider filter showed 0 count.
|
||||
|
||||
**Investigation**:
|
||||
- Added `itunes_podcasts` to provider capabilities and music provider domains
|
||||
- Added synthetic provider instance creation for search-only providers
|
||||
- Added debug logging to understand podcast loading
|
||||
|
||||
**Resolution**: The issue was with Music Assistant's iTunes podcast provider itself - podcasts added via iTunes weren't being properly stored in the MA library. The user removed the iTunes provider from MA and re-added podcasts via Spotify search as a workaround.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/provider_instance.dart` - Added `itunes_podcasts` to capabilities
|
||||
|
||||
### 3. Episode Loading Failing for Non-Library Podcasts
|
||||
|
||||
**Problem**: When a podcast was removed from library, the old library ID became invalid and episodes wouldn't load.
|
||||
|
||||
**Solution**: Added fallback logic to try loading episodes via provider mappings when the primary ID fails.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart` - Added fallback episode loading
|
||||
|
||||
### 4. Search Filtering Rework
|
||||
|
||||
**Problem**: Server-side provider filtering was being applied to search results when it should only apply when the "in library" toggle is enabled.
|
||||
|
||||
**Solution**:
|
||||
- Removed `filterSearchResults()` calls from search methods
|
||||
- Added client-side `_filterByEnabledProviders()` helper
|
||||
- Applied filtering only when `_libraryOnly` toggle is ON
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/providers/music_assistant_provider.dart` - Removed filterSearchResults calls
|
||||
- `lib/screens/search_screen.dart` - Added client-side filtering
|
||||
|
||||
### 5. Library Buttons Not Updating Immediately
|
||||
|
||||
**Problem**: Add/remove library buttons on detail screens waited for API response before updating, causing perceived lag.
|
||||
|
||||
**Solution**: Implemented optimistic updates - update UI immediately, revert on failure.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart`
|
||||
- `lib/screens/album_details_screen.dart`
|
||||
- `lib/screens/artist_details_screen.dart`
|
||||
|
||||
## Code Changes
|
||||
|
||||
### ProviderMapping.inLibrary Field
|
||||
|
||||
```dart
|
||||
// lib/models/media_item.dart
|
||||
class ProviderMapping {
|
||||
final String itemId;
|
||||
final String providerDomain;
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
final bool inLibrary; // NEW: indicates ownership
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderMapping(
|
||||
// ... other fields ...
|
||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Podcast Provider Counting
|
||||
|
||||
```dart
|
||||
// lib/providers/music_assistant_provider.dart
|
||||
Map<String, int> getProvidersWithPodcasts() {
|
||||
final counts = <String, int>{};
|
||||
for (final item in _podcasts) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
```
|
||||
|
||||
### Optimistic Update Pattern
|
||||
|
||||
```dart
|
||||
// Example from podcast_detail_screen.dart
|
||||
Future<void> _toggleLibrary() async {
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.addedToLibrary)),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(...).catchError((e) {
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side Search Filtering
|
||||
|
||||
```dart
|
||||
// lib/screens/search_screen.dart
|
||||
Map<String, List<MediaItem>> _filterByEnabledProviders(
|
||||
Map<String, List<MediaItem>> results,
|
||||
Set<String> enabledProviders,
|
||||
) {
|
||||
if (enabledProviders.isEmpty) return results;
|
||||
|
||||
bool isItemAllowed(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings == null || mappings.isEmpty) return true;
|
||||
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
|
||||
}
|
||||
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowed).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
// Applied when setting results:
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Capabilities Update
|
||||
|
||||
```dart
|
||||
// lib/models/provider_instance.dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'}, // NEW
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
// ... other providers
|
||||
};
|
||||
```
|
||||
|
||||
## Summary of All Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/media_item.dart` | Added `inLibrary` field to ProviderMapping |
|
||||
| `lib/models/provider_instance.dart` | Added `itunes_podcasts`, podcasts to capabilities |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated podcast counting, removed filterSearchResults |
|
||||
| `lib/screens/new_library_screen.dart` | Updated podcast filtering with inLibrary check |
|
||||
| `lib/screens/search_screen.dart` | Added client-side filtering for in-library mode |
|
||||
| `lib/screens/podcast_detail_screen.dart` | Optimistic updates, fallback episode loading |
|
||||
| `lib/screens/album_details_screen.dart` | Optimistic updates for library button |
|
||||
| `lib/screens/artist_details_screen.dart` | Optimistic updates for library button |
|
||||
|
||||
## Key Learnings
|
||||
|
||||
1. **inLibrary field**: Music Assistant's provider mappings include an `in_library` field that indicates whether the user added the item from that specific provider account. This is essential for accurate per-provider filtering.
|
||||
|
||||
2. **iTunes podcast provider**: The MA iTunes podcast provider is for search only - it doesn't properly store items in the library. Users should add podcasts from streaming providers like Spotify instead.
|
||||
|
||||
3. **Optimistic updates**: For better UX, library toggle buttons should update immediately and only revert if the API call fails, rather than waiting for API response.
|
||||
|
||||
4. **Search vs Library filtering**: Server-side provider filtering should only apply to library views, not search results (unless the "in library" toggle is enabled).
|
||||
@@ -0,0 +1,263 @@
|
||||
# Provider Filtering Improvements
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on improving the provider filtering system across the app, building on the per-provider sync implementation. Key improvements include a cleaner capability-based architecture, fixing filter visibility bugs, and extending filtering to the audiobooks library.
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Provider Capability Mapping
|
||||
|
||||
**File:** `lib/models/provider_instance.dart`
|
||||
|
||||
Replaced the clunky item-count-based filtering with a static capability map that defines what content types each provider domain supports.
|
||||
|
||||
```dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Self-hosted media servers (can have music and audiobooks)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Audiobook-only providers
|
||||
'audiobookshelf': {'audiobooks'},
|
||||
|
||||
// Radio providers
|
||||
'tunein': {'radio'},
|
||||
'radiobrowser': {'radio'},
|
||||
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
```
|
||||
|
||||
**Added helper methods:**
|
||||
```dart
|
||||
Set<String> get supportedContentTypes => providerCapabilities[domain] ?? <String>{};
|
||||
bool supportsContentType(String category) => supportedContentTypes.contains(category);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Radio providers never show in Artists tab (by design, not by count)
|
||||
- Works immediately, even with empty libraries
|
||||
- Disabled providers still show in menu (if they support the content type)
|
||||
- Clean separation of concerns
|
||||
|
||||
---
|
||||
|
||||
### 2. Updated Provider Filtering in MusicAssistantProvider
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart`
|
||||
|
||||
Updated `getRelevantProvidersForCategory()` to filter by capability:
|
||||
|
||||
```dart
|
||||
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
|
||||
// ... get counts ...
|
||||
|
||||
final result = <(ProviderInstance, int)>[];
|
||||
for (final provider in _availableMusicProviders) {
|
||||
// Only include providers that support this content type
|
||||
if (provider.supportsContentType(category)) {
|
||||
final count = counts[provider.instanceId] ?? 0;
|
||||
result.add((provider, count));
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Simplified Library Menu Provider Filter
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Simplified the options menu overlay to show all providers that support the current category:
|
||||
|
||||
**Before (clunky):**
|
||||
```dart
|
||||
if (widget.relevantProviders.where((p) {
|
||||
final hasItems = p.$2 > 0;
|
||||
final isDisabledByUser = widget.enabledProviderIds.isNotEmpty &&
|
||||
!widget.enabledProviderIds.contains(p.$1.instanceId);
|
||||
return hasItems || isDisabledByUser;
|
||||
}).length > 1) ...[
|
||||
```
|
||||
|
||||
**After (clean):**
|
||||
```dart
|
||||
// relevantProviders is pre-filtered by capability
|
||||
if (widget.relevantProviders.length > 1) ...[
|
||||
// Show all providers that support this category
|
||||
...widget.relevantProviders.map((providerData) {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Removed Provider Toggles from Settings Screen
|
||||
|
||||
**File:** `lib/screens/settings_screen.dart`
|
||||
|
||||
Removed the "Music Providers" section entirely since provider filtering is now handled in the library menu:
|
||||
|
||||
- Removed `_providerEnabled` state variable
|
||||
- Removed provider loading code from `_loadSettings()`
|
||||
- Removed the entire Music Providers UI section (~100 lines)
|
||||
|
||||
**Rationale:** Single source of truth - provider toggles now only appear in the library menu where they're contextually relevant.
|
||||
|
||||
---
|
||||
|
||||
### 5. Fixed Audiobooks Library Provider Filtering
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
The audiobooks tabs were not filtering by provider - they loaded data once and displayed it without responding to provider changes.
|
||||
|
||||
**Fixed `_buildBooksAuthorsTab()`:**
|
||||
```dart
|
||||
final syncService = SyncService.instance;
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// Filter by favorites if enabled
|
||||
if (_showFavoritesOnly) {
|
||||
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
|
||||
}
|
||||
|
||||
// Group filtered audiobooks by author (dynamic, not cached)
|
||||
final groupedByAuthor = <String, List<Audiobook>>{};
|
||||
for (final book in audiobooks) {
|
||||
final authorName = book.authorsString;
|
||||
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
|
||||
}
|
||||
```
|
||||
|
||||
**Fixed `_buildAllBooksTab()`:**
|
||||
- Same provider filtering pattern
|
||||
- Dynamic sorting after filtering
|
||||
- Updated PageStorageKey to include provider count for proper rebuild
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Provider Filter Menu Disappearing
|
||||
|
||||
**Problem:** When providers were deselected, the background sync would clear the cache and only fetch data for enabled providers. Disabled providers then had 0 items, and the menu required `> 1 providers WITH items` to show. The entire Providers section would disappear.
|
||||
|
||||
**Solution:** Changed to capability-based filtering. The menu now shows all providers that support the category, regardless of item count.
|
||||
|
||||
### Audiobooks Not Filtering by Provider
|
||||
|
||||
**Problem:** Audiobooks tabs loaded data once into `_audiobooks` and displayed it without provider filtering. Toggling providers in the menu had no effect.
|
||||
|
||||
**Solution:** Added inline provider filtering during build (same pattern as music tabs).
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/provider_instance.dart` | Added `providerCapabilities` map and helper methods |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated `getRelevantProvidersForCategory()` to filter by capability |
|
||||
| `lib/screens/new_library_screen.dart` | Simplified menu filter logic, added audiobook provider filtering |
|
||||
| `lib/screens/settings_screen.dart` | Removed Music Providers section |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Provider Capability Map │
|
||||
│ (ProviderInstance.providerCapabilities) │
|
||||
│ │
|
||||
│ spotify → {artists, albums, tracks, playlists, audiobooks} │
|
||||
│ audiobookshelf → {audiobooks} │
|
||||
│ tunein → {radio} │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ getRelevantProvidersForCategory(category) │
|
||||
│ │
|
||||
│ - Filters _availableMusicProviders by capability │
|
||||
│ - Returns only providers that support the category │
|
||||
│ - Includes item counts for display │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Library Menu (Options) │
|
||||
│ │
|
||||
│ - Shows providers section if > 1 providers support category │
|
||||
│ - All relevant providers shown (can toggle on/off) │
|
||||
│ - Instant UI update via setState() │
|
||||
│ - Background sync after 1.5s debounce │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Tab Build Methods │
|
||||
│ │
|
||||
│ - Get enabledProviderIds from MusicAssistantProvider │
|
||||
│ - Filter data using SyncService source tracking │
|
||||
│ - Display filtered results │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab in audiobooks doesn't filter by provider yet. Series are fetched separately as `AudiobookSeries` objects and don't have the same source tracking as individual audiobooks.
|
||||
|
||||
2. **First Sync Required:** Source tracking only works after at least one sync with providers enabled.
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Music tabs filter by provider correctly
|
||||
- [x] Provider menu shows only relevant providers per category
|
||||
- [x] Radio providers don't appear in Artists/Albums tabs
|
||||
- [x] Spotify appears in Audiobooks tab (has audiobooks)
|
||||
- [x] Audiobooks Authors tab filters by provider
|
||||
- [x] Audiobooks All Books tab filters by provider
|
||||
- [ ] Audiobooks Series tab filters by provider (not implemented)
|
||||
- [x] Disabled providers can be re-enabled from menu
|
||||
- [x] Settings screen no longer has provider toggles
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Original per-provider sync implementation
|
||||
@@ -0,0 +1,242 @@
|
||||
# Discovery Rows Submenu - Implementation Plan
|
||||
|
||||
## Option: Hybrid with Discovery Badge
|
||||
|
||||
### Overview
|
||||
Transform the "Discovery Mixes" toggle into a section that:
|
||||
1. Shows individual discovery row toggles
|
||||
2. Adds those rows to the main home row list with visual distinction
|
||||
3. Allows full reordering alongside other rows
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Settings Screen - Discovery Section
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ⚬ Discovery Mixes ● │
|
||||
│ Show discovery-based rows on home screen │
|
||||
│ ──────────────────────────────────────── │
|
||||
│ Discovery rows from Music Assistant: │
|
||||
│ │
|
||||
│ ☑ New Releases ● │
|
||||
│ ☑ Recently Played ● │
|
||||
│ ☐ Recommended Albums ○ │
|
||||
│ ☐ For You ○ │
|
||||
│ │
|
||||
│ [Refresh available rows] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Main Home Row List - With Badge
|
||||
|
||||
Discovery rows appear interleaved with a badge:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ⋮ ☑ Recent Albums ● │
|
||||
│ ⋮ ☑ Discovery: New Releases ● [MUSIC] │ ← Badge
|
||||
│ ⋮ ☑ Discover Artists ● │
|
||||
│ ⋮ ☐ Discovery: For You ○ [MUSIC] │
|
||||
│ ⋮ ☑ Favorite Albums ● │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Badge style: Small chip/icon indicating this is a dynamic discovery row.
|
||||
|
||||
---
|
||||
|
||||
## Technical Changes
|
||||
|
||||
### 1. Settings Screen (`lib/screens/settings_screen.dart`)
|
||||
|
||||
**Add state for discovery folders:**
|
||||
```dart
|
||||
List<RecommendationFolder> _discoveryFolders = [];
|
||||
Map<String, bool> _discoveryRowEnabled = {}; // itemId -> enabled
|
||||
bool _isDiscoveryFoldersExpanded = false;
|
||||
```
|
||||
|
||||
**New methods:**
|
||||
```dart
|
||||
Future<void> _loadDiscoveryFolders() async { }
|
||||
Future<void> _refreshDiscoveryFolders() async { }
|
||||
void _toggleDiscoveryRow(String itemId, bool enabled) { }
|
||||
void _toggleDiscoveryMixes(bool enabled) { }
|
||||
Widget _buildDiscoverySection() { }
|
||||
```
|
||||
|
||||
**Modify existing:**
|
||||
- `_loadSettings()` - load discovery row preferences
|
||||
- Home row list builder - filter out individual discovery rows
|
||||
- Add discovery section before home row list
|
||||
|
||||
### 2. Settings Service (`lib/services/settings_service.dart`)
|
||||
|
||||
**New methods:**
|
||||
```dart
|
||||
static Future<bool> getShowDiscoveryFolders() async { }
|
||||
static Future<void> setShowDiscoveryFolders(bool value) async { }
|
||||
|
||||
// Per-row discovery preferences
|
||||
static Future<Map<String, bool>> getDiscoveryRowPreferences() async { }
|
||||
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async { }
|
||||
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async { }
|
||||
```
|
||||
|
||||
**Storage key pattern:**
|
||||
- `discovery_folders_enabled` (bool)
|
||||
- `discovery_row_{itemId}` (bool per row)
|
||||
|
||||
### 3. Provider (`lib/providers/music_assistant_provider.dart`)
|
||||
|
||||
**Already has:** `getDiscoveryFoldersWithCache()`
|
||||
|
||||
**Add method:**
|
||||
```dart
|
||||
Future<void> refreshDiscoveryFolders() async {
|
||||
// Clear cache and refetch from API
|
||||
_cacheService.clearDiscoveryFoldersCache();
|
||||
await getDiscoveryFoldersWithCache();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Home Screen (`lib/screens/new_home_screen.dart`)
|
||||
|
||||
**Changes to row rendering:**
|
||||
|
||||
Instead of a single `_discoveryFolders` list, discovery rows are integrated into `_homeRowOrder`:
|
||||
|
||||
```dart
|
||||
// Before: static list of row IDs
|
||||
List<String> _homeRowOrder = ['recent-albums', 'discover-artists', ...];
|
||||
|
||||
// After: can include discovery row IDs
|
||||
List<String> _homeRowOrder = [
|
||||
'recent-albums',
|
||||
'discovery:abc123', // Discovery row ID
|
||||
'discover-artists',
|
||||
'discovery:def456',
|
||||
...
|
||||
];
|
||||
```
|
||||
|
||||
**New method:**
|
||||
```dart
|
||||
bool _isDiscoveryRowId(String rowId) {
|
||||
return rowId.startsWith('discovery:');
|
||||
}
|
||||
|
||||
String _getDiscoveryRowItemId(String rowId) {
|
||||
return rowId.substring('discovery:'.length);
|
||||
}
|
||||
```
|
||||
|
||||
**Modify row rendering:**
|
||||
- Check if rowId is a discovery row
|
||||
- If yes, render DiscoveryRow widget with that folder
|
||||
- Cache discovery folders for quick lookup
|
||||
|
||||
### 5. Localization (`lib/l10n/app_en.arb`)
|
||||
|
||||
**Add strings:**
|
||||
```json
|
||||
{
|
||||
"discoveryMixesDescription": "Show personalized recommendations from Music Assistant",
|
||||
"discoveryRows": "Discovery rows from Music Assistant",
|
||||
"refreshDiscoveryRows": "Refresh available rows",
|
||||
"noDiscoveryRows": "No discovery rows available",
|
||||
"discoveryRowBadge": "Music Assistant"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Models (`lib/models/recommendation_folder.dart`)
|
||||
|
||||
**Add property:**
|
||||
```dart
|
||||
class RecommendationFolder {
|
||||
...
|
||||
/// Unique ID for use in row ordering and settings
|
||||
String get rowId => 'discovery:$itemId';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Settings Screen │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. Load discovery folders from Provider │
|
||||
│ 2. Load per-row enabled state from Settings │
|
||||
│ 3. Render section with toggles │
|
||||
│ 4. User toggles row → save to Settings │
|
||||
│ 5. User toggles main → save to Settings + update home row list │
|
||||
│ 6. User refreshes → Provider.fetch → update UI │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Home Screen │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 1. Load home row order from Settings │
|
||||
│ 2. Identify discovery row IDs (prefix "discovery:") │
|
||||
│ 3. For discovery rows: look up folder from Provider cache │
|
||||
│ 4. Render appropriate widget (DiscoveryRow or other) │
|
||||
│ 5. Handle reordering including discovery rows │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Settings Screen (No breaking changes)
|
||||
- Add discovery section to settings
|
||||
- Load and display discovery folders
|
||||
- Individual row toggles (but don't affect home screen yet)
|
||||
- Refresh button
|
||||
|
||||
### Phase 2: Home Screen Integration
|
||||
- Modify home row order to support discovery row IDs
|
||||
- Render discovery rows in main list
|
||||
- Add badge visual distinction
|
||||
|
||||
### Phase 3: Polish
|
||||
- Translations
|
||||
- Edge cases (folder disappears from MA)
|
||||
- Performance (lazy loading)
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases & Considerations
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Discovery folder no longer returned by MA | Keep user preference, hide row if not found |
|
||||
| User reorders then MA returns new folders | New folders appended to end of discovery section |
|
||||
| All discovery rows disabled | Discovery section shows but appears empty/minimized |
|
||||
| Many discovery folders (10+) | Show scrollable sublist, keep main setting compact |
|
||||
| Row ID collision | Use `discovery:{itemId}` prefix ensures uniqueness |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Settings: Add `_discoveryFolders` state
|
||||
- [ ] Settings: Add `_discoveryRowEnabled` map
|
||||
- [ ] Settings: Implement `_loadDiscoveryFolders()`
|
||||
- [ ] Settings: Implement `_buildDiscoverySection()`
|
||||
- [ ] Settings: Add refresh button handler
|
||||
- [ ] SettingsService: Add discovery row preference methods
|
||||
- [ ] Provider: Add `refreshDiscoveryFolders()`
|
||||
- [ ] HomeScreen: Support discovery row IDs in order list
|
||||
- [ ] HomeScreen: Render discovery rows with badge
|
||||
- [ ] Localization: Add new strings
|
||||
- [ ] Test: Toggle discovery rows on/off
|
||||
- [ ] Test: Reorder discovery rows
|
||||
- [ ] Test: Refresh discovery folders
|
||||
- [ ] Test: Migrate existing preference to new system
|
||||
@@ -0,0 +1,221 @@
|
||||
# Feature: Replace "Play On" Popups with Player Selector
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the `PlayerPickerSheet` popup modal with the existing Player Selector (reveal overlay) when users tap "Play on" buttons throughout the app. This provides a consistent, richer player selection experience.
|
||||
|
||||
## Current State
|
||||
|
||||
### PlayerPickerSheet Popup
|
||||
Used in 7+ places:
|
||||
- `album_details_screen.dart` - Play Album, Play From Here, Play Radio
|
||||
- `playlist_details_screen.dart` - Play Playlist, Play Radio
|
||||
- `search_screen.dart` - Play album/playlist/audiobook/radio from search
|
||||
- `podcast_detail_screen.dart` - Play Episode
|
||||
- `audiobook_detail_screen.dart` - Play Audiobook
|
||||
- `queue_screen.dart` - Transfer Queue (special case - keep popup)
|
||||
|
||||
### Player Selector (Reveal Overlay)
|
||||
- Location: `lib/widgets/player/player_reveal_overlay.dart`
|
||||
- Shows vertical list of all players with artwork, current track, sync status
|
||||
- Has hints above player list: "Long-press to sync" & "Swipe to adjust volume"
|
||||
- Triggered by pull-down gesture or device button tap
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Modify PlayerRevealOverlay
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart`
|
||||
|
||||
Add new optional parameters:
|
||||
|
||||
```dart
|
||||
class PlayerRevealOverlay extends StatefulWidget {
|
||||
final VoidCallback onDismiss;
|
||||
final double miniPlayerBottom;
|
||||
final double miniPlayerHeight;
|
||||
final bool showOnboardingHints;
|
||||
|
||||
// NEW: Callback when player is selected for a pending action
|
||||
final void Function(Player player)? onPlayerSelected;
|
||||
|
||||
// NEW: Context hint to display instead of default hints
|
||||
// e.g., "Select player to play album"
|
||||
final String? contextHint;
|
||||
|
||||
// NEW: Icon for context hint (default: speaker icon)
|
||||
final IconData? contextHintIcon;
|
||||
```
|
||||
|
||||
### 2. Modify Hint Display
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 386-394)
|
||||
|
||||
```dart
|
||||
// Current:
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
|
||||
// New:
|
||||
: widget.contextHint != null
|
||||
? buildHintRow(
|
||||
widget.contextHintIcon ?? Icons.play_circle_outline,
|
||||
widget.contextHint!,
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
### 3. Modify Player Card onTap
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 437-441 and 490-494)
|
||||
|
||||
```dart
|
||||
// Current:
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(data.player);
|
||||
dismiss();
|
||||
},
|
||||
|
||||
// New:
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
if (widget.onPlayerSelected != null) {
|
||||
// Execute the pending action with selected player
|
||||
widget.onPlayerSelected!(data.player);
|
||||
} else {
|
||||
// Default behavior: just select the player
|
||||
maProvider.selectPlayer(data.player);
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
```
|
||||
|
||||
### 4. Add Helper Method to GlobalPlayerOverlay
|
||||
|
||||
**File:** `lib/widgets/global_player_overlay.dart`
|
||||
|
||||
Add a static method to show player selector with pending action:
|
||||
|
||||
```dart
|
||||
/// Show player selector for a "Play on" action
|
||||
///
|
||||
/// [contextHint] - Message shown instead of default hints
|
||||
/// [onPlayerSelected] - Called when user taps a player
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
/// contextHint: "Select player to play album",
|
||||
/// onPlayerSelected: (player) {
|
||||
/// provider.playAlbum(albumId, playerId: player.playerId);
|
||||
/// },
|
||||
/// );
|
||||
/// ```
|
||||
static void showPlayerSelectorForAction({
|
||||
required String contextHint,
|
||||
required void Function(Player player) onPlayerSelected,
|
||||
IconData? hintIcon,
|
||||
}) {
|
||||
// Implementation: trigger the player reveal overlay with these parameters
|
||||
// May need to store pending action in state and pass to overlay
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Update Calling Sites
|
||||
|
||||
Replace `showPlayerPickerSheet()` calls with new method:
|
||||
|
||||
**Example - album_details_screen.dart:**
|
||||
|
||||
```dart
|
||||
// Current (around line 1305-1316):
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: 'Play Album',
|
||||
onPlayerSelected: (player) async {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
await maProvider.playAlbum(albumId, playerId: player.playerId);
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
},
|
||||
);
|
||||
|
||||
// New:
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum, // Add localization
|
||||
onPlayerSelected: (player) async {
|
||||
await maProvider.playAlbum(albumId, playerId: player.playerId);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### 6. Add Localizations
|
||||
|
||||
**File:** `lib/l10n/app_en.arb` (and other language files)
|
||||
|
||||
```json
|
||||
"selectPlayerToPlay": "Select player to play",
|
||||
"selectPlayerToPlayAlbum": "Select player to play album",
|
||||
"selectPlayerToPlayPlaylist": "Select player to play playlist",
|
||||
"selectPlayerToPlayRadio": "Select player to start radio",
|
||||
"selectPlayerToPlayEpisode": "Select player to play episode",
|
||||
"selectPlayerToPlayAudiobook": "Select player to play audiobook"
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `lib/widgets/player/player_reveal_overlay.dart` - Add parameters, modify hints & onTap
|
||||
2. `lib/widgets/global_player_overlay.dart` - Add helper method
|
||||
3. `lib/screens/album_details_screen.dart` - Replace 3 popup calls
|
||||
4. `lib/screens/playlist_details_screen.dart` - Replace 2 popup calls
|
||||
5. `lib/screens/search_screen.dart` - Replace ~5 popup calls
|
||||
6. `lib/screens/podcast_detail_screen.dart` - Replace 1 popup call
|
||||
7. `lib/screens/audiobook_detail_screen.dart` - Replace 1 popup call
|
||||
8. `lib/l10n/app_en.arb` - Add new strings
|
||||
9. `lib/l10n/app_de.arb`, `app_fr.arb`, `app_es.arb` - Translations
|
||||
|
||||
## Exception: Queue Transfer
|
||||
|
||||
**Keep the popup for `queue_screen.dart`** because:
|
||||
- Needs to filter out current player (can't transfer to self)
|
||||
- Needs to filter out certain player types
|
||||
- Different UX intent (transfer vs play)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Modify `PlayerRevealOverlay` to accept new parameters
|
||||
2. Add helper method to `GlobalPlayerOverlay`
|
||||
3. Test with one screen (album_details_screen.dart)
|
||||
4. Add localizations
|
||||
5. Migrate remaining screens
|
||||
6. Clean up unused code from PlayerPickerSheet if no longer needed elsewhere
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Album details: Play Album button
|
||||
- [ ] Album details: Play From Here (track context menu)
|
||||
- [ ] Album details: Play Radio (track context menu)
|
||||
- [ ] Playlist details: Play Playlist button
|
||||
- [ ] Playlist details: Play Radio (track context menu)
|
||||
- [ ] Search: Play album from results
|
||||
- [ ] Search: Play playlist from results
|
||||
- [ ] Search: Play radio from results
|
||||
- [ ] Podcast detail: Play episode
|
||||
- [ ] Audiobook detail: Play audiobook
|
||||
- [ ] Queue transfer still works with popup
|
||||
- [ ] Long-press to sync still works in player selector
|
||||
- [ ] Dismiss by tapping backdrop works
|
||||
- [ ] Dismiss by swiping down works
|
||||
@@ -0,0 +1,328 @@
|
||||
# Remote Connection - Future Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides guidance for implementing remote connection to Music Assistant servers via WebRTC. Remote connection allows the Ensemble app to connect to MA servers outside the local network using Nabu Casa's WebRTC signaling infrastructure.
|
||||
|
||||
## Current Status: On Hold
|
||||
|
||||
Remote connection was partially implemented but put on hold due to a critical bug where the `ma-api` data channel becomes one-way after 1-3 minutes. See `MA_WEBRTC_BUG_REPORT.md` for details.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌──────────────────────────┐
|
||||
│ Ensemble App │◄──────────────────►│ MA Server (local WS) │
|
||||
└─────────────────┘ └──────────────────────────┘
|
||||
│ │
|
||||
│ (when local unavailable) │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────────┐
|
||||
│ SignalingClient│◄──── WebSocket ───►│ signaling.music-assistant│
|
||||
└─────────────────┘ │ .io/ws │
|
||||
│ └──────────────────────────┘
|
||||
│ ICE candidates, SDP │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────────┐
|
||||
│ WebRTC Peer │◄═══ Data Channel ══►│ MA Server WebRTC │
|
||||
│ Connection │ (ma-api) │ Endpoint │
|
||||
└─────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
1. **SignalingClient** - WebSocket connection to `wss://signaling.music-assistant.io/ws`
|
||||
2. **WebRTCConnection** - Manages RTCPeerConnection and data channels
|
||||
3. **RemoteBridge** - Bridges local WebSocket API to remote WebRTC connection
|
||||
|
||||
### Data Channels
|
||||
|
||||
| Channel | Purpose | Traffic Pattern |
|
||||
|---------|---------|-----------------|
|
||||
| `ma-api` | JSON API requests/responses | High volume, large payloads |
|
||||
| `sendspin` | Audio streaming + control | Low JSON, binary audio data |
|
||||
|
||||
---
|
||||
|
||||
## Key Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/remote/signaling_client.dart` | WebSocket client for signaling server |
|
||||
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection management |
|
||||
| `lib/services/remote/remote_bridge.dart` | Bridges API calls to WebRTC |
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/services/music_assistant_api.dart` | Add remote connection fallback |
|
||||
| `lib/providers/music_assistant_provider.dart` | Expose remote connection state |
|
||||
| `pubspec.yaml` | Add `flutter_webrtc` dependency |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add Dependencies
|
||||
|
||||
```yaml
|
||||
# pubspec.yaml
|
||||
dependencies:
|
||||
flutter_webrtc: ^0.12.0 # or latest
|
||||
```
|
||||
|
||||
### Step 2: Implement SignalingClient
|
||||
|
||||
The signaling client connects to Music Assistant's signaling server and exchanges WebRTC offers/answers.
|
||||
|
||||
```dart
|
||||
class SignalingClient {
|
||||
WebSocket? _socket;
|
||||
|
||||
Future<void> connect(String serverId) async {
|
||||
_socket = await WebSocket.connect('wss://signaling.music-assistant.io/ws');
|
||||
|
||||
// Send connect request
|
||||
_send({
|
||||
'type': 'connect-request',
|
||||
'server_id': serverId,
|
||||
});
|
||||
|
||||
// Listen for messages
|
||||
_socket!.listen(_handleMessage);
|
||||
}
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
final message = jsonDecode(data);
|
||||
switch (message['type']) {
|
||||
case 'answer':
|
||||
onAnswer?.call(message['sdp']);
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
onIceCandidate?.call(message['candidate']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Implement WebRTCConnection
|
||||
|
||||
```dart
|
||||
class WebRTCConnection {
|
||||
RTCPeerConnection? _peerConnection;
|
||||
RTCDataChannel? _apiChannel;
|
||||
RTCDataChannel? _sendspinChannel;
|
||||
|
||||
Future<void> connect(SignalingClient signaling) async {
|
||||
// Create peer connection with STUN/TURN servers
|
||||
_peerConnection = await createPeerConnection({
|
||||
'iceServers': [
|
||||
{'urls': 'stun:stun.l.google.com:19302'},
|
||||
// TURN servers provided by signaling
|
||||
],
|
||||
});
|
||||
|
||||
// Create data channels BEFORE creating offer
|
||||
_apiChannel = await _peerConnection!.createDataChannel(
|
||||
'ma-api',
|
||||
RTCDataChannelInit()..ordered = true,
|
||||
);
|
||||
|
||||
_sendspinChannel = await _peerConnection!.createDataChannel(
|
||||
'sendspin',
|
||||
RTCDataChannelInit()..ordered = true,
|
||||
);
|
||||
|
||||
// Create and send offer
|
||||
final offer = await _peerConnection!.createOffer();
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
signaling.sendOffer(offer.sdp!);
|
||||
|
||||
// Handle answer from server
|
||||
signaling.onAnswer = (sdp) async {
|
||||
await _peerConnection!.setRemoteDescription(
|
||||
RTCSessionDescription(sdp, 'answer'),
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Implement RemoteBridge
|
||||
|
||||
The bridge translates between the local WebSocket API format and WebRTC data channel messages.
|
||||
|
||||
```dart
|
||||
class RemoteBridge {
|
||||
final WebRTCConnection _webrtc;
|
||||
final StreamController<String> _messageController;
|
||||
|
||||
void sendCommand(String json) {
|
||||
_webrtc.sendToApiChannel(json);
|
||||
}
|
||||
|
||||
Stream<String> get messages => _messageController.stream;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Integrate with MusicAssistantAPI
|
||||
|
||||
```dart
|
||||
class MusicAssistantAPI {
|
||||
WebSocket? _localSocket;
|
||||
RemoteBridge? _remoteBridge;
|
||||
|
||||
Future<void> connect(String host, {String? serverId}) async {
|
||||
try {
|
||||
// Try local connection first
|
||||
_localSocket = await WebSocket.connect('ws://$host/ws');
|
||||
} catch (e) {
|
||||
// Fall back to remote if serverId provided
|
||||
if (serverId != null) {
|
||||
_remoteBridge = RemoteBridge();
|
||||
await _remoteBridge!.connect(serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Implementation Details
|
||||
|
||||
### 1. Channel Creation Order
|
||||
|
||||
The client MUST create data channels BEFORE sending the WebRTC offer. The server expects the client to create:
|
||||
- `ma-api` - for API traffic
|
||||
- `sendspin` - for audio streaming (optional)
|
||||
|
||||
### 2. Health Monitoring
|
||||
|
||||
Implement aggressive health checks because data channels can silently die:
|
||||
|
||||
```dart
|
||||
// Track message counts
|
||||
int _sentCount = 0;
|
||||
int _recvCount = 0;
|
||||
DateTime _lastRecvTime = DateTime.now();
|
||||
|
||||
// Health check every 10 seconds
|
||||
void _healthCheck() {
|
||||
final timeSinceRecv = DateTime.now().difference(_lastRecvTime);
|
||||
if (timeSinceRecv.inSeconds > 30) {
|
||||
// Channel is dead, trigger reconnection
|
||||
_reconnect();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Keepalive Messages
|
||||
|
||||
Send periodic keepalive on the data channel itself (not just signaling):
|
||||
|
||||
```dart
|
||||
void _sendKeepalive() {
|
||||
_apiChannel?.send(RTCDataChannelMessage(
|
||||
jsonEncode({'type': 'ping', 'timestamp': DateTime.now().toIso8601String()}),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Flow Control
|
||||
|
||||
Monitor buffer to prevent overflow:
|
||||
|
||||
```dart
|
||||
void sendMessage(String json) {
|
||||
if (_apiChannel!.bufferedAmount > 1048576) { // 1MB
|
||||
// Queue message, don't send
|
||||
_sendQueue.add(json);
|
||||
return;
|
||||
}
|
||||
_apiChannel!.send(RTCDataChannelMessage(json));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Pitfalls
|
||||
|
||||
### 1. One-Way Channel Death (CRITICAL - UNSOLVED)
|
||||
|
||||
The `ma-api` channel stops receiving after 1-3 minutes while `sendspin` continues working. This occurs in both flutter_webrtc and WebView implementations.
|
||||
|
||||
**Workaround options:**
|
||||
- Detect via health check and force reconnection
|
||||
- Try single-channel multiplexing (see `PLAN_B_SINGLE_CHANNEL.md`)
|
||||
- Report to MA team and wait for server-side fix
|
||||
|
||||
### 2. flutter_webrtc Quirks
|
||||
|
||||
- `RTCDataChannel.state` may show "open" when actually dead
|
||||
- `bufferedAmount` accuracy varies by platform
|
||||
- `onBufferedAmountLow` callback support inconsistent
|
||||
|
||||
### 3. Android Background Throttling
|
||||
|
||||
Android aggressively throttles background WebRTC. Consider:
|
||||
- Foreground service for sustained connections
|
||||
- Reconnection on app resume
|
||||
|
||||
### 4. ICE/TURN Issues
|
||||
|
||||
- aiortc (server-side) has known TURN + DataChannel bugs
|
||||
- May need ICE restart on network changes
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Connect via remote when local unavailable
|
||||
- [ ] Verify API commands work (players/all, library queries)
|
||||
- [ ] Test sustained connection (30+ minutes)
|
||||
- [ ] Test app backgrounding and resume
|
||||
- [ ] Test network transitions (WiFi ↔ cellular)
|
||||
- [ ] Verify reconnection after channel death
|
||||
- [ ] Test sendspin audio streaming (if implemented)
|
||||
|
||||
---
|
||||
|
||||
## Useful Resources
|
||||
|
||||
- [Music Assistant Remote Access Docs](https://music-assistant.io/docs/remote-access/)
|
||||
- [flutter_webrtc Package](https://pub.dev/packages/flutter_webrtc)
|
||||
- [WebRTC Data Channel MDN](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel)
|
||||
- Signaling server: `wss://signaling.music-assistant.io/ws`
|
||||
|
||||
---
|
||||
|
||||
## Files in This Directory
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `FUTURE_IMPLEMENTATION_GUIDE.md` | This file - implementation guide |
|
||||
| `MA_WEBRTC_BUG_REPORT.md` | Bug report for MA team |
|
||||
| `WEBRTC_DISCONNECT_FIXES.md` | All fixes attempted |
|
||||
| `WEBRTC_PROGRESS.md` | Progress log |
|
||||
| `PLAN_B_SINGLE_CHANNEL.md` | Alternative single-channel approach |
|
||||
| `webrtc-failure-logs.txt` | Raw failure logs |
|
||||
| `console-export-*.log` | Browser console exports |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start for Future Implementation
|
||||
|
||||
1. Read this guide fully
|
||||
2. Review `WEBRTC_DISCONNECT_FIXES.md` for all attempted fixes
|
||||
3. Check if MA team has fixed server-side issues
|
||||
4. Start with basic implementation, add health monitoring early
|
||||
5. Test extensively before shipping - the failure mode is subtle
|
||||
@@ -0,0 +1,127 @@
|
||||
# WebRTC Data Channel Failure on Android - Bug Report
|
||||
|
||||
## Summary
|
||||
|
||||
When connecting to Music Assistant via WebRTC remote access (Nabu Casa), the `ma-api` data channel becomes **one-way** (can send, cannot receive) after 1-3 minutes of use, while the `sendspin` channel continues working normally.
|
||||
|
||||
## Environment
|
||||
|
||||
- **MA Server**: 2.8.0b7 (Home Assistant add-on)
|
||||
- **Client**: Android app (Flutter) connecting via Nabu Casa remote access
|
||||
- **Remote Access**: WebRTC via `wss://signaling.music-assistant.io/ws`
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
1. Connect to MA server via WebRTC remote access (not local network)
|
||||
2. Use the app normally - browse library, view players
|
||||
3. After 1-3 minutes, the connection degrades
|
||||
4. Commands time out, but signaling ping/pong continues working
|
||||
|
||||
## Observed Behavior
|
||||
|
||||
### Timeline from logs:
|
||||
|
||||
```
|
||||
07:14:20 - Working normally
|
||||
Health: API sent:51 recv:54 (balanced)
|
||||
|
||||
07:15:57 - Receive stops
|
||||
Health: API sent:97 recv:97, lastRecv:12s ago
|
||||
|
||||
07:16:02 - Timeouts begin
|
||||
"TimeoutException: Command timeout: players/all"
|
||||
|
||||
07:16:17 - Sendspin still works!
|
||||
"Sendspin: Received server/time response"
|
||||
Health: API sent:114 recv:97, lastRecv:27s ago
|
||||
|
||||
07:17:30 - Forced reconnect
|
||||
Health: API sent:137 recv:97, lastRecv:42s ago
|
||||
"Health check detected stale connection"
|
||||
```
|
||||
|
||||
### Key Observation
|
||||
|
||||
| Channel | Traffic Pattern | Status |
|
||||
|---------|-----------------|--------|
|
||||
| `ma-api` | High (10+ msg/s, large payloads) | **FAILS** - stops receiving |
|
||||
| `sendspin` | Low (~1 msg/5s, small payloads) | **SURVIVES** - continues working |
|
||||
| Signaling | Ping/pong every 15s | **SURVIVES** - continues working |
|
||||
|
||||
The WebRTC peer connection remains in "connected" state. ICE connection shows "completed". Only the ma-api data channel stops receiving.
|
||||
|
||||
## Testing Performed
|
||||
|
||||
### Test 1: flutter_webrtc (Native WebRTC)
|
||||
- **Result**: ma-api channel dies after 1-3 minutes
|
||||
- **Sendspin**: Continues working
|
||||
|
||||
### Test 2: WebView-based WebRTC (Browser engine)
|
||||
- **Hypothesis**: Use Android WebView's browser-native WebRTC, since MA Web UI works
|
||||
- **Implementation**: Hidden InAppWebView running JavaScript WebRTC engine
|
||||
- **Result**: **Same failure mode** - ma-api dies, sendspin survives
|
||||
- **Conclusion**: Not a flutter_webrtc bug
|
||||
|
||||
### Test 3: Various Data Channel Settings
|
||||
- Tried `ordered: false` with `maxRetransmits: 3`
|
||||
- Tried letting server create channels (failed - server expects client to create)
|
||||
- No improvement
|
||||
|
||||
## Analysis
|
||||
|
||||
The identical failure in both native and WebView WebRTC implementations suggests the issue is:
|
||||
|
||||
1. **Server-side** - Different handling of ma-api vs sendspin channels
|
||||
2. **TURN relay** - High-traffic channel may hit limits
|
||||
3. **Not client-specific** - Both implementations fail identically
|
||||
|
||||
### Questions for MA Team
|
||||
|
||||
1. Are the two data channels (`ma-api`, `sendspin`) created with different settings?
|
||||
2. Is there any server-side buffering or rate limiting that could affect high-traffic channels?
|
||||
3. Does the MA Web UI use different data channel configurations?
|
||||
4. Are there known issues with the TURN relay for sustained high-traffic channels?
|
||||
|
||||
## Comparison: MA Web UI vs Android App
|
||||
|
||||
| Aspect | MA Web UI | Android App |
|
||||
|--------|-----------|-------------|
|
||||
| Platform | Browser tab (visible, active) | Background-capable app |
|
||||
| WebRTC | Browser native | flutter_webrtc / WebView |
|
||||
| Result | Works | Fails after 1-3 min |
|
||||
|
||||
The MA Web UI runs in a **visible, active browser tab**. Android may throttle background apps differently, but this doesn't explain why sendspin survives while ma-api dies on the same connection.
|
||||
|
||||
## Logs
|
||||
|
||||
### Health Check Pattern (shows channel death)
|
||||
```
|
||||
07:14:25 API sent:51 recv:54, lastRecv:2s ago <- Working
|
||||
07:15:35 API sent:87 recv:91, lastRecv:2s ago <- Working
|
||||
07:16:05 API sent:108 recv:97, lastRecv:17s ago <- DYING (gap growing)
|
||||
07:16:15 API sent:114 recv:97, lastRecv:27s ago <- DEAD
|
||||
07:16:45 API sent:130 recv:97, lastRecv:27s ago <- Still sending, no receive
|
||||
07:17:30 API sent:137 recv:97, lastRecv:42s ago <- Forced reconnect
|
||||
```
|
||||
|
||||
### Sendspin Still Working During Failure
|
||||
```
|
||||
07:16:17 Sendspin: Received server/time response <- Still alive!
|
||||
07:16:47 Sendspin: Received server/time response <- Still alive!
|
||||
```
|
||||
|
||||
## Requested Information
|
||||
|
||||
1. Data channel creation code/settings for both channels
|
||||
2. Any server-side differences in channel handling
|
||||
3. TURN server configuration details
|
||||
4. Whether this has been reported by other Android/mobile clients
|
||||
|
||||
## Workaround
|
||||
|
||||
Currently using local WebSocket connection when on same network. Remote access via WebRTC is unusable for sustained use.
|
||||
|
||||
---
|
||||
|
||||
**Reporter**: Ensemble Android App Developer
|
||||
**Date**: 2026-01-24
|
||||
@@ -0,0 +1,149 @@
|
||||
# Plan B: Single Data Channel Multiplexing
|
||||
|
||||
## Why This Might Be Needed
|
||||
|
||||
If upgrading flutter_webrtc doesn't fix the one-way channel failure, the root cause is likely a bug in how flutter_webrtc (or underlying libwebrtc) handles **multiple data channels**.
|
||||
|
||||
Evidence:
|
||||
- MA Web UI works fine with local playback (same server, browser WebRTC)
|
||||
- Our app uses flutter_webrtc which has known issues:
|
||||
- [Issue #1428](https://github.com/flutter-webrtc/flutter-webrtc/issues/1428): Threading issues causing data loss
|
||||
- [Issue #974](https://github.com/flutter-webrtc/flutter-webrtc/issues/974): EventChannel intermittently fails
|
||||
- Pattern matches: second channel survives while first dies
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ WebRTC Peer Connection │
|
||||
├────────────────────┬────────────────────────────┤
|
||||
│ ma-api channel │ sendspin channel │
|
||||
│ (JSON API) │ (JSON + binary audio) │
|
||||
│ SCTP stream 0 │ SCTP stream 1 │
|
||||
└────────────────────┴────────────────────────────┘
|
||||
```
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ WebRTC Peer Connection │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ unified channel │
|
||||
│ (multiplexed: API + Sendspin text + binary) │
|
||||
│ SCTP stream 0 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Multiplexing Protocol
|
||||
|
||||
### Message Framing
|
||||
|
||||
Each message has a 1-byte type prefix:
|
||||
|
||||
| Prefix | Type | Content |
|
||||
|--------|------|---------|
|
||||
| 0x00 | API | JSON text (MA API request/response) |
|
||||
| 0x01 | Sendspin Text | JSON text (Sendspin control messages) |
|
||||
| 0x02 | Sendspin Binary | Raw bytes (PCM audio data) |
|
||||
|
||||
### Implementation
|
||||
|
||||
#### Sending (Client → Server)
|
||||
|
||||
```dart
|
||||
void sendApiMessage(String json) {
|
||||
final prefix = Uint8List.fromList([0x00]);
|
||||
final payload = utf8.encode(json);
|
||||
final message = Uint8List(1 + payload.length);
|
||||
message[0] = 0x00;
|
||||
message.setRange(1, message.length, payload);
|
||||
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
|
||||
}
|
||||
|
||||
void sendSendspinText(String json) {
|
||||
final prefix = Uint8List.fromList([0x01]);
|
||||
final payload = utf8.encode(json);
|
||||
final message = Uint8List(1 + payload.length);
|
||||
message[0] = 0x01;
|
||||
message.setRange(1, message.length, payload);
|
||||
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
|
||||
}
|
||||
|
||||
void sendSendspinBinary(Uint8List audio) {
|
||||
final message = Uint8List(1 + audio.length);
|
||||
message[0] = 0x02;
|
||||
message.setRange(1, message.length, audio);
|
||||
_dataChannel.send(RTCDataChannelMessage.fromBinary(message));
|
||||
}
|
||||
```
|
||||
|
||||
#### Receiving (Server → Client)
|
||||
|
||||
```dart
|
||||
void _onMessage(RTCDataChannelMessage message) {
|
||||
final data = message.binary;
|
||||
if (data == null || data.isEmpty) return;
|
||||
|
||||
final type = data[0];
|
||||
final payload = data.sublist(1);
|
||||
|
||||
switch (type) {
|
||||
case 0x00: // API
|
||||
final json = utf8.decode(payload);
|
||||
_handleApiMessage(json);
|
||||
break;
|
||||
case 0x01: // Sendspin text
|
||||
final json = utf8.decode(payload);
|
||||
_handleSendspinText(json);
|
||||
break;
|
||||
case 0x02: // Sendspin binary
|
||||
_handleSendspinBinary(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Side Changes Required
|
||||
|
||||
The Music Assistant server would need to:
|
||||
1. Accept a single "unified" data channel instead of separate ma-api and sendspin channels
|
||||
2. Multiplex responses using the same framing protocol
|
||||
3. This requires changes to the MA remote connection code
|
||||
|
||||
**Alternative**: Keep server unchanged, do multiplexing in a proxy layer on the client.
|
||||
|
||||
## Client-Only Approach (No Server Changes)
|
||||
|
||||
If we can't change the server, we could:
|
||||
|
||||
1. Still create two channels to the server (ma-api + sendspin)
|
||||
2. But internally route them through a single "virtual" channel for flutter_webrtc
|
||||
3. This is more complex and may not avoid the underlying bug
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `webrtc_connection.dart` | Single channel creation, multiplexing logic |
|
||||
| `remote_bridge.dart` | Route messages based on type prefix |
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Server compatibility**: Requires MA server changes or complex client proxy
|
||||
2. **Performance**: Extra byte per message, decode overhead
|
||||
3. **Binary handling**: Must handle all messages as binary (not text)
|
||||
|
||||
## Testing Plan
|
||||
|
||||
1. Implement client-side changes
|
||||
2. Test with modified server (if available) or mock
|
||||
3. Verify both API and Sendspin traffic flows correctly
|
||||
4. Long-duration stability test
|
||||
|
||||
## Decision Point
|
||||
|
||||
Try this approach if:
|
||||
- flutter_webrtc 0.14.x doesn't fix the issue
|
||||
- The one-way failure pattern continues
|
||||
- We've confirmed the issue is multi-channel specific
|
||||
@@ -0,0 +1,406 @@
|
||||
# WebRTC Data Channel Disconnect Fixes
|
||||
|
||||
## Document Purpose
|
||||
Track all fixes attempted for the WebRTC data channel disconnection issues in Ensemble's remote connection feature. This document should be referenced when debugging future disconnection problems.
|
||||
|
||||
---
|
||||
|
||||
## Problem Summary
|
||||
|
||||
The WebRTC data channel between the Ensemble mobile app and Music Assistant server can fail in several ways:
|
||||
|
||||
1. **One-way failure**: Data channel appears "open" but only sends work, receives stop
|
||||
2. **Silent disconnect**: No error events fire, channel just stops working
|
||||
3. **ICE failure**: ICE connection fails, properly detected but causes disconnect
|
||||
4. **Signaling disconnect**: Signaling WebSocket closes, causing ICE/WebRTC to fail
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/remote/webrtc_connection.dart` | WebRTC peer connection, data channel management |
|
||||
| `lib/services/remote/remote_bridge.dart` | WebSocket bridge, health monitoring, reconnection logic |
|
||||
| `lib/services/remote/signaling_client.dart` | Signaling server WebSocket connection |
|
||||
|
||||
---
|
||||
|
||||
## Implemented Fixes (Chronological)
|
||||
|
||||
### Fix 1: Data Channel Keepalive (Original)
|
||||
**Problem**: Data channel could silently die without any error events firing.
|
||||
|
||||
**Solution**: Added periodic keepalive ping/pong on the data channel itself.
|
||||
|
||||
**Code** (`webrtc_connection.dart`):
|
||||
- `_keepaliveTimer`: Runs every 10 seconds
|
||||
- `_checkKeepalive()`: Checks time since last data received
|
||||
- `_sendKeepalivePing()`: Sends JSON ping message
|
||||
- `_recordDataReceived()`: Called on every message receipt
|
||||
|
||||
**Constants**:
|
||||
```dart
|
||||
static const _keepaliveInterval = Duration(seconds: 10);
|
||||
static const _keepaliveTimeout = Duration(seconds: 20); // Reduced from 30
|
||||
static const _maxKeepaliveMisses = 2; // Reduced from 3
|
||||
```
|
||||
|
||||
### Fix 2: ICE Recovery
|
||||
**Problem**: ICE connection could temporarily disconnect and might auto-recover.
|
||||
|
||||
**Solution**: Added ICE recovery timer before declaring failure.
|
||||
|
||||
**Code** (`webrtc_connection.dart`):
|
||||
- `_startIceRecovery()`: Starts 5-second timer when ICE disconnects
|
||||
- `_cancelIceRecovery()`: Cancels if ICE recovers on its own
|
||||
- `_attemptIceRestart()`: Calls `restartIce()` if recovery timer expires
|
||||
|
||||
### Fix 3: Flow Control (Buffer Overflow Prevention)
|
||||
**Problem**: Fire-and-forget sends could overflow the 16MB SCTP buffer, causing silent channel death.
|
||||
|
||||
**Solution**: Added send-side flow control with buffering.
|
||||
|
||||
**Code** (`webrtc_connection.dart`):
|
||||
```dart
|
||||
static const _maxBufferedAmount = 1048576; // 1MB threshold
|
||||
static const _bufferLowThreshold = 65536; // 64KB resume threshold
|
||||
bool _sendPaused = false;
|
||||
final _sendQueue = <String>[];
|
||||
```
|
||||
|
||||
**Modified `sendRaw()`**:
|
||||
- Checks `bufferedAmount` before sending
|
||||
- Queues messages if buffer > 1MB
|
||||
- `onBufferedAmountLow` callback drains queue
|
||||
|
||||
### Fix 4: Health Check Stale Detection
|
||||
**Problem**: Health check could see "30s since last data" but not trigger reconnection.
|
||||
|
||||
**Solution**: Reduced stale detection threshold from 45s to 30s.
|
||||
|
||||
**Code** (`remote_bridge.dart`):
|
||||
```dart
|
||||
if (_state == RemoteBridgeState.connected && timeSinceLastData > 30 && !_isReconnecting) {
|
||||
// Force reconnection
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 5: Reduced Keepalive Timeouts
|
||||
**Problem**: 30s timeout × 3 misses = 90s minimum before detection.
|
||||
|
||||
**Solution**: Reduced to 20s timeout × 2 misses = ~40s detection.
|
||||
|
||||
### Fix 6: Recv Stagnation Detection
|
||||
**Problem**: One-way failure where recv stops but sent continues not detected quickly enough.
|
||||
|
||||
**Solution**: Track recv count across health checks. If recv stuck while sent increases for 2 consecutive checks (20s), force reconnection.
|
||||
|
||||
**Code** (`remote_bridge.dart`):
|
||||
```dart
|
||||
// Track across health checks
|
||||
int _lastHealthCheckRecvCount = 0;
|
||||
int _lastHealthCheckSentCount = 0;
|
||||
int _recvStagnationCount = 0;
|
||||
|
||||
// In health check:
|
||||
if (recvStuck && sentIncreased) {
|
||||
_recvStagnationCount++;
|
||||
if (_recvStagnationCount >= 2) {
|
||||
// Force reconnection - channel is one-way
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Detection time**: ~20 seconds (2 × 10s health check interval)
|
||||
|
||||
---
|
||||
|
||||
## Log Patterns to Watch For
|
||||
|
||||
### Healthy Connection
|
||||
```
|
||||
Health check - API sent:93 recv:88 ... lastRecv:0s ago
|
||||
```
|
||||
- `sent` and `recv` both increasing
|
||||
- `lastRecv` low (< 5s typically)
|
||||
|
||||
### One-Way Failure (CRITICAL)
|
||||
```
|
||||
Health check - API sent:206 recv:163 ... lastRecv:30s ago
|
||||
Health check - API sent:218 recv:163 ... lastRecv:40s ago
|
||||
```
|
||||
- `sent` increases, `recv` STUCK
|
||||
- `lastRecv` keeps increasing
|
||||
- Data channel still shows "open"
|
||||
|
||||
### Signaling Disconnect
|
||||
```
|
||||
WebRTC: Signaling disconnected
|
||||
... (6-16 seconds later) ...
|
||||
WebRTC: ICE connection state: RTCIceConnectionStateDisconnected
|
||||
WebRTC: ICE connection state: RTCIceConnectionStateFailed
|
||||
```
|
||||
|
||||
### Network Failure (DNS)
|
||||
```
|
||||
Signaling: WebSocket closed
|
||||
... (reconnection attempt) ...
|
||||
Signaling: Failed to connect: SocketException: Failed host lookup: 'signaling.music-assistant.io'
|
||||
```
|
||||
This indicates the phone lost network connectivity entirely.
|
||||
|
||||
### Reconnection Storm (Signaling Instability)
|
||||
```
|
||||
Signaling: Connecting to wss://signaling.music-assistant.io/ws
|
||||
Signaling: Sending connect-request
|
||||
Signaling: WebSocket closed ← Closes within 100-500ms
|
||||
WebRTC: Signaling disconnected
|
||||
RemoteBridge: Scheduling reconnect attempt X/10 in Ys
|
||||
```
|
||||
Rapid reconnection loop - signaling never stays connected long enough.
|
||||
|
||||
### Flow Control Engaged
|
||||
```
|
||||
WebRTC: ⚠️ Buffer full (1234567 bytes) or paused - queueing message
|
||||
WebRTC: Buffer drained to 12345 bytes - draining 5 queued messages
|
||||
WebRTC: ✅ Send queue drained (5 messages) - resuming normal sends
|
||||
```
|
||||
|
||||
### Keepalive Detection
|
||||
```
|
||||
WebRTC: ⚠️ Keepalive timeout! No data for 22s (missed: 1/2)
|
||||
WebRTC: ❌ Data channel appears dead - triggering reconnection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Limitations
|
||||
|
||||
### 1. flutter_webrtc Limitations
|
||||
- `RTCDataChannel.state` may show "open" when actually dead (Issue #843)
|
||||
- `bufferedAmount` may not be accurate on all platforms
|
||||
- `onBufferedAmountLow` callback support varies
|
||||
|
||||
### 2. SCTP Receive Window
|
||||
- If server sends faster than client processes, receive window fills
|
||||
- This causes server-side to stop sending, client sees no data
|
||||
- No way to monitor receive-side buffer from client
|
||||
|
||||
### 3. TURN Server Issues
|
||||
- aiortc (server-side) has known issues with TURN + DataChannel (#1156)
|
||||
- Data channel may fail silently when using TURN relay
|
||||
|
||||
### 4. Network Transitions
|
||||
- WiFi → Cellular or vice versa can cause ICE to fail
|
||||
- App backgrounding on mobile can close WebSocket/WebRTC
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Categories
|
||||
|
||||
### Category A: Network Issues (Not Code Bugs)
|
||||
These are environmental issues, not bugs in our code:
|
||||
- **DNS failure**: `Failed host lookup` - Phone lost internet
|
||||
- **WiFi/Cellular switch**: Network changed, connections dropped
|
||||
- **Server unreachable**: Signaling server down or blocked
|
||||
- **Firewall/NAT issues**: TURN relay needed but unavailable
|
||||
|
||||
**Evidence**: Signaling WebSocket closes, DNS lookups fail, reconnection attempts fail repeatedly.
|
||||
|
||||
### Category B: WebRTC/SCTP Issues (Partial Code Control)
|
||||
These can be mitigated but not fully prevented:
|
||||
- **One-way channel death**: Channel appears open but data stops flowing
|
||||
- **Buffer overflow**: Too much data sent, SCTP buffer exceeded
|
||||
- **ICE failure**: Network path lost, no recovery possible
|
||||
|
||||
**Evidence**: Data channel shows "open" but `recv` stuck, `lastRecv` increasing.
|
||||
|
||||
### Category C: Code Bugs (Fixable)
|
||||
Issues in our implementation:
|
||||
- **Detection too slow**: Timeouts too long, misses too many
|
||||
- **Recovery not triggered**: State checks wrong, reconnection not started
|
||||
- **Resource leaks**: Timers not cancelled, connections not cleaned up
|
||||
|
||||
**Evidence**: Logs show problem but no action taken, or wrong action taken.
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
When investigating a disconnect:
|
||||
|
||||
1. **Find the last successful response**:
|
||||
```
|
||||
grep "WebRTC API → WS" logs.txt | head -5
|
||||
```
|
||||
|
||||
2. **Check health check progression**:
|
||||
```
|
||||
grep "Health check" logs.txt
|
||||
```
|
||||
Look for `recv` getting stuck while `sent` increases.
|
||||
|
||||
3. **Check for signaling issues**:
|
||||
```
|
||||
grep -i "signaling" logs.txt
|
||||
```
|
||||
|
||||
4. **Check ICE state changes**:
|
||||
```
|
||||
grep "ICE connection state" logs.txt
|
||||
```
|
||||
|
||||
5. **Check for keepalive detection**:
|
||||
```
|
||||
grep -i "keepalive" logs.txt
|
||||
```
|
||||
|
||||
6. **Check buffer status**:
|
||||
```
|
||||
grep -E "(buffer|queue|paused)" logs.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Potential Future Improvements
|
||||
|
||||
### 1. Server-Side Backpressure
|
||||
MA protocol doesn't have explicit backpressure. Could add:
|
||||
- Client-side request throttling
|
||||
- Application-level ACKs for critical messages
|
||||
|
||||
### 2. More Aggressive Detection
|
||||
- Track recv count across health checks, detect stagnation
|
||||
- Shorter keepalive interval (5s instead of 10s)
|
||||
|
||||
### 3. Proactive Health Signals
|
||||
- Monitor `bufferedAmount` trend (increasing = problem)
|
||||
- Track round-trip latency of keepalive pings
|
||||
|
||||
### 4. Connection Quality Metrics
|
||||
- Expose packet loss, jitter from WebRTC stats
|
||||
- Use to predict imminent failure
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
| Scenario | How to Test | Expected Behavior |
|
||||
|----------|-------------|-------------------|
|
||||
| Normal usage | Browse library, play music | No warnings |
|
||||
| Heavy sync | Pull-to-refresh library | Buffer may queue, should drain |
|
||||
| Long session | Leave connected 2+ hours | Should stay connected |
|
||||
| Rapid navigation | Quick tab switching | Brief queue usage, recovers |
|
||||
| Network change | WiFi → Cellular | Should detect and reconnect |
|
||||
| App background | Background for 5+ min | May disconnect, should reconnect |
|
||||
|
||||
---
|
||||
|
||||
## Observed Failure Patterns
|
||||
|
||||
### Pattern 1: One-Way Death After Library Sync
|
||||
**Observed**: 2026-01-23 (multiple times)
|
||||
|
||||
```
|
||||
17:13:06.175 - Last response (playlists sync)
|
||||
17:13:06.264 - Local batch save
|
||||
-- 5 seconds silence --
|
||||
17:13:11.005 - Send players/all → NO RESPONSE
|
||||
17:13:13.914 - ICE state: completed (looks healthy!)
|
||||
17:13:28 - Keepalive detects timeout
|
||||
17:13:38 - Reconnection triggered
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- Often happens after library sync (large batch responses)
|
||||
- ICE shows "completed" even though data channel is dead
|
||||
- Signaling ping/pong continues working
|
||||
- Only data channel receive is affected
|
||||
|
||||
**Hypothesis**:
|
||||
- Server-side SCTP buffer exhausted from sending large responses?
|
||||
- Client receive window full, server blocks?
|
||||
- flutter_webrtc onMessage callback stops firing?
|
||||
|
||||
### Pattern 2: Reconnection Storm / Signaling Instability
|
||||
**Observed**: 2026-01-23 17:30-17:31
|
||||
|
||||
```
|
||||
17:31:05.093 - Signaling: Connecting
|
||||
17:31:05.216 - Signaling: Sending connect-request
|
||||
17:31:05.218 - Signaling: WebSocket closed (123ms later!)
|
||||
17:31:05.218 - WebRTC: Signaling disconnected
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- Signaling WebSocket closes almost immediately after connect
|
||||
- Multiple rapid reconnection attempts (storm)
|
||||
- Connection never stable long enough for data flow
|
||||
- Often happens after network instability
|
||||
- Artist detail, albums etc don't load because connection keeps failing
|
||||
|
||||
**Possible Causes**:
|
||||
- Network instability (WiFi/cellular issues)
|
||||
- Signaling server rate limiting
|
||||
- DNS resolution failures
|
||||
- Server rejecting rapid reconnection attempts
|
||||
|
||||
**Side Effects**:
|
||||
- UI shows errors like "Not connected to Music Assistant server"
|
||||
- Artist/album detail screens fail to load
|
||||
- Players list empty or stale
|
||||
|
||||
### Pattern 3: API Channel Dies While Sendspin Lives
|
||||
**Observed**: 2026-01-23 17:53
|
||||
|
||||
```
|
||||
17:53:35.755 - Send album_tracks request → NO RESPONSE
|
||||
17:53:40.596 - Health check: recv stuck at 197, sent increased
|
||||
17:53:55.204 - Sendspin channel RECEIVES server/time response!
|
||||
17:54:02.790 - Keepalive detects dead API channel
|
||||
```
|
||||
|
||||
**Characteristics**:
|
||||
- API data channel (ma-api) stops receiving
|
||||
- Sendspin data channel continues working normally
|
||||
- Same WebRTC peer connection, different data channels
|
||||
- This proves it's NOT a network issue - Sendspin traffic flows fine
|
||||
|
||||
**Hypothesis**:
|
||||
- flutter_webrtc bug where `onMessage` callback stops firing for one channel
|
||||
- Possible memory/GC issue with the callback
|
||||
- Server-side might be sending to wrong channel?
|
||||
|
||||
**Key Evidence**:
|
||||
- Recv count stays at 197 for API
|
||||
- Sendspin recv count increases (7→8)
|
||||
- Both channels use same peer connection
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Date | Build | Changes |
|
||||
|------|-------|---------|
|
||||
| 2026-01-23 | ~v49 | Initial flow control implementation |
|
||||
| 2026-01-23 | ~v49 | Reduced keepalive timeout 30→20s, misses 3→2 |
|
||||
| 2026-01-23 | ~v49 | Reduced health check stale threshold 45→30s |
|
||||
| 2026-01-23 | ~v49 | Fixed excessive "queue drained" logging |
|
||||
| 2026-01-23 | ~v49 | Created WEBRTC_DISCONNECT_FIXES.md documentation |
|
||||
| 2026-01-23 | ~v49 | Added recv stagnation detection (20s detection) |
|
||||
| 2026-01-23 | ~v49 | Documented Pattern 2: Reconnection storm / signaling instability |
|
||||
| 2026-01-23 | ~v49 | Fixed race condition in _scheduleReconnect (multiple simultaneous calls) |
|
||||
| 2026-01-23 | ~v49 | Added WebSocket close code/reason logging to signaling |
|
||||
| 2026-01-23 | ~v49 | **CRITICAL FIX**: _reconnectWebRTC() was returning immediately due to _isReconnecting already being true |
|
||||
| 2026-01-23 | ~v49 | Fixed old WebRTC callbacks firing during reconnection cleanup (clear callbacks before disconnect) |
|
||||
| 2026-01-23 | ~v49 | Fixed onStateChanged handler calling _handleWebRTCDisconnection during active reconnection |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- flutter_webrtc Issue #843: Data channel state unreliable
|
||||
- aiortc Issue #1156: TURN + DataChannel bugs
|
||||
- WebRTC SCTP: Max buffer is 16MB, recommended threshold 1MB
|
||||
- Music Assistant remote protocol: No explicit flow control
|
||||
@@ -0,0 +1,118 @@
|
||||
# WebRTC Remote Connection Progress
|
||||
|
||||
## Problem Statement
|
||||
The flutter_webrtc library has reliability issues causing data channel failures during remote connections via Nabu Casa. The ma-api data channel becomes one-way (can send, but receives stop) after some time, while the sendspin channel continues working.
|
||||
|
||||
## Approaches Tried
|
||||
|
||||
### 1. Original Implementation (flutter_webrtc)
|
||||
- **Status**: Fails
|
||||
- **Issue**: ma-api data channel goes one-way after some time
|
||||
- **Files**: `lib/services/remote/webrtc_connection.dart`
|
||||
|
||||
### 2. WebView-based WebRTC Implementation
|
||||
- **Status**: Same failure mode
|
||||
- **Hypothesis**: Use browser-native WebRTC via InAppWebView, since MA Web UI works
|
||||
- **Result**: Same one-way channel death occurs
|
||||
- **Key Finding**: This proves the issue is NOT specific to flutter_webrtc
|
||||
|
||||
## Key Observations
|
||||
|
||||
From the logs:
|
||||
```
|
||||
lastRecv:26s ago <- No messages received in 26 seconds
|
||||
API sent:674 recv:618 <- Gap growing, responses stopped
|
||||
```
|
||||
|
||||
- Signaling ping/pong continues working
|
||||
- Sendspin channel continues receiving (`server/time` responses)
|
||||
- Only ma-api channel becomes one-way
|
||||
- WebRTC peer connection itself remains "connected"
|
||||
|
||||
## Differences from Working MA Web UI
|
||||
|
||||
The MA Web UI runs in a **visible, active browser tab**. Our implementations:
|
||||
1. flutter_webrtc: Native WebRTC in background app
|
||||
2. WebView: Browser WebRTC in **hidden 1x1 pixel WebView**
|
||||
|
||||
Android aggressively throttles hidden WebViews to save battery.
|
||||
|
||||
## Current Implementation (WebView)
|
||||
|
||||
### Files Created
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/services/remote/webview_webrtc_connection.dart` | Dart WebView wrapper |
|
||||
| `assets/webrtc_engine.js` | JavaScript WebRTC engine |
|
||||
|
||||
### Files Modified
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `pubspec.yaml` | Added `flutter_inappwebview: ^6.0.0` |
|
||||
| `lib/services/remote/remote_bridge.dart` | Uses `WebViewWebRTCConnection` |
|
||||
| `lib/providers/music_assistant_provider.dart` | Exposes WebView widget |
|
||||
| `lib/main.dart` | Adds WebView to widget tree |
|
||||
|
||||
### Architecture
|
||||
```
|
||||
MusicAssistantAPI -> localhost WebSocket -> RemoteBridge
|
||||
|
|
||||
WebViewWebRTCConnection
|
||||
|
|
||||
Hidden InAppWebView
|
||||
|
|
||||
JavaScript WebRTC Engine
|
||||
|
|
||||
MA Server via TURN
|
||||
```
|
||||
|
||||
## Current Fixes Being Tested
|
||||
|
||||
### 1. Prevent WebView Throttling
|
||||
- Changed WebView position from off-screen (-10,-10) to on-screen (0,0) with Opacity(0)
|
||||
- Added various InAppWebViewSettings to prevent background throttling
|
||||
|
||||
### 2. Data Channel Settings
|
||||
- Changed from `ordered: true` to `ordered: false` with `maxRetransmits: 3`
|
||||
- Hypothesis: Ordered delivery may cause buffer bloat issues
|
||||
|
||||
### 3. Buffer Monitoring
|
||||
- Added `bufferedAmount` checking in JavaScript
|
||||
- Logs warning when buffer exceeds 1MB
|
||||
- Added `getChannelHealth()` function for diagnostics
|
||||
|
||||
### 4. Reduced Logging
|
||||
- Removed per-message logging (was filling logs too fast)
|
||||
- Keep only state changes, errors, and warnings
|
||||
|
||||
## Experiments Completed
|
||||
|
||||
### 5. Server-Created Channels (Failed)
|
||||
- **Hypothesis**: Let server create data channels to avoid ID collisions
|
||||
- **Result**: Connection timeout - server expects CLIENT to create channels
|
||||
- **Learning**: Client must create channels before sending offer
|
||||
|
||||
## Next Steps to Try
|
||||
|
||||
1. **Make WebView actually visible** - Android may still throttle Opacity(0) WebView
|
||||
2. **Single data channel** - Use only ma-api, disable sendspin
|
||||
3. **Periodic channel recreation** - Detect stale channel and recreate
|
||||
4. **Report to MA team** - Browser WebRTC also fails, suggesting server-side issue
|
||||
|
||||
## Test APK Location
|
||||
`/home/chris/Ensemble/ensemble-webview-webrtc.apk`
|
||||
|
||||
## Useful Debug Commands
|
||||
|
||||
Check channel health (in JS console or via Dart):
|
||||
```javascript
|
||||
getChannelHealth()
|
||||
// Returns: { peerConnection, iceConnection, apiChannel, apiBuffered, sendspinChannel, sendspinBuffered, signalingConnected }
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Why does sendspin channel survive when ma-api dies?
|
||||
2. Is there a difference in how MA server handles the two channels?
|
||||
3. Does MA Web UI use different data channel settings?
|
||||
4. Is there a TURN relay issue specific to high-traffic channels?
|
||||
@@ -0,0 +1,2 @@
|
||||
New package ID (com.collotsspot.ensemble) - reinstall required.
|
||||
Prepares for F-Droid and Play Store submission.
|
||||
@@ -0,0 +1 @@
|
||||
Add fastlane metadata for F-Droid.
|
||||
@@ -0,0 +1 @@
|
||||
Disable dependency metadata in APK for F-Droid compliance.
|
||||
@@ -0,0 +1,13 @@
|
||||
Ensemble is an unofficial, community-built mobile client for Music Assistant, providing both local playback and remote control of your music library.
|
||||
|
||||
Features:
|
||||
* Stream music directly to your phone with background playback
|
||||
* Control any speaker or device connected to Music Assistant
|
||||
* Multi-room grouping capabilities
|
||||
* Queue management with drag-to-reorder
|
||||
* Background playback with media notifications
|
||||
* Fast ~300ms response for play/pause controls
|
||||
|
||||
Ensemble supports both local playback (streaming to your phone) and remote control of any Music Assistant player in your home.
|
||||
|
||||
Note: Requires a running Music Assistant server. This is a community project and is not affiliated with the official Music Assistant team.
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 909 KiB |
@@ -0,0 +1 @@
|
||||
Unofficial mobile client for Music Assistant
|
||||
@@ -0,0 +1 @@
|
||||
Ensemble
|
||||
@@ -29,6 +29,15 @@ class HeroTags {
|
||||
/// Podcast cover hero tag prefix
|
||||
static const String podcastCover = 'podcast_cover_';
|
||||
|
||||
/// Podcast title hero tag prefix
|
||||
static const String podcastTitle = 'podcast_title_';
|
||||
|
||||
/// Podcast episode cover hero tag prefix
|
||||
static const String podcastEpisodeCover = 'podcast_episode_cover_';
|
||||
|
||||
/// Podcast episode title hero tag prefix
|
||||
static const String podcastEpisodeTitle = 'podcast_episode_title_';
|
||||
|
||||
/// Playlist cover hero tag prefix
|
||||
static const String playlistCover = 'playlist_cover_';
|
||||
|
||||
|
||||
@@ -10,4 +10,7 @@ class NetworkConstants {
|
||||
|
||||
/// Default HTTP port
|
||||
static const int defaultHttpPort = 80;
|
||||
|
||||
/// Maximum number of pending WebSocket requests to prevent memory leaks
|
||||
static const int maxPendingRequests = 100;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ class Timings {
|
||||
/// Search debounce delay
|
||||
static const Duration searchDebounce = Duration(milliseconds: 500);
|
||||
|
||||
/// Navigation debounce to prevent double-tap navigation
|
||||
static const Duration navigationDebounce = Duration(milliseconds: 500);
|
||||
|
||||
/// Delay after track change before updating state
|
||||
static const Duration trackChangeDelay = Duration(milliseconds: 500);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ class LibraryCache extends Table {
|
||||
/// Whether this item was deleted on the server
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {cacheKey};
|
||||
}
|
||||
@@ -191,7 +195,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 5;
|
||||
int get schemaVersion => 6;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -226,6 +230,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)');
|
||||
}
|
||||
// Migration from v5 to v6: Add source_providers column for client-side filtering
|
||||
if (from < 6) {
|
||||
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -368,20 +376,57 @@ class AppDatabase extends _$AppDatabase {
|
||||
// ============================================
|
||||
|
||||
/// Cache a library item
|
||||
/// [sourceProvider] - the provider instance ID that provided this item (for filtering)
|
||||
Future<void> cacheItem({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required String data,
|
||||
String? sourceProvider,
|
||||
}) async {
|
||||
final cacheKey = '${itemType}_$itemId';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
// If sourceProvider is specified, we need to merge with existing providers
|
||||
if (sourceProvider != null) {
|
||||
final existing = await getCachedItem(itemType, itemId);
|
||||
List<String> providers = [];
|
||||
if (existing != null) {
|
||||
try {
|
||||
final existingProviders = existing.sourceProviders;
|
||||
if (existingProviders.isNotEmpty && existingProviders != '[]') {
|
||||
providers = List<String>.from(
|
||||
(await Future.value(existingProviders))
|
||||
.replaceAll('[', '')
|
||||
.replaceAll(']', '')
|
||||
.replaceAll('"', '')
|
||||
.split(',')
|
||||
.where((s) => s.trim().isNotEmpty)
|
||||
.map((s) => s.trim()),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!providers.contains(sourceProvider)) {
|
||||
providers.add(sourceProvider);
|
||||
}
|
||||
final sourceProvidersJson = '[${providers.map((p) => '"$p"').join(',')}]';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
sourceProviders: Value(sourceProvidersJson),
|
||||
));
|
||||
} else {
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached items by type
|
||||
@@ -650,4 +695,66 @@ class AppDatabase extends _$AppDatabase {
|
||||
Future<void> clearSearchHistory() async {
|
||||
await delete(searchHistory).go();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Batch Cache Operations
|
||||
// ============================================
|
||||
|
||||
/// Batch size for chunked inserts to prevent SQLite limits
|
||||
static const int _batchChunkSize = 500;
|
||||
|
||||
/// Batch cache multiple items efficiently using drift batch API
|
||||
/// This is much faster than individual inserts for large datasets.
|
||||
/// Items are chunked into batches of 500 to prevent SQLite variable limits.
|
||||
///
|
||||
/// [items] - List of items to cache, each containing:
|
||||
/// - itemType: 'album', 'artist', 'track', 'playlist', 'audiobook', etc.
|
||||
/// - itemId: the item's ID
|
||||
/// - data: JSON string of the item
|
||||
/// - sourceProviders: list of provider instance IDs
|
||||
Future<void> batchCacheItems(List<BatchCacheItem> items) async {
|
||||
if (items.isEmpty) return;
|
||||
|
||||
// Process in chunks to avoid SQLite variable limits
|
||||
for (var i = 0; i < items.length; i += _batchChunkSize) {
|
||||
final chunk = items.skip(i).take(_batchChunkSize).toList();
|
||||
|
||||
await batch((b) {
|
||||
for (final item in chunk) {
|
||||
final cacheKey = '${item.itemType}_${item.itemId}';
|
||||
final sourceProvidersJson = item.sourceProviders.isEmpty
|
||||
? '[]'
|
||||
: '[${item.sourceProviders.map((p) => '"$p"').join(',')}]';
|
||||
|
||||
b.insert(
|
||||
libraryCache,
|
||||
LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: item.itemType,
|
||||
itemId: item.itemId,
|
||||
data: item.data,
|
||||
lastSynced: DateTime.now(),
|
||||
sourceProviders: Value(sourceProvidersJson),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data class for batch cache operations
|
||||
class BatchCacheItem {
|
||||
final String itemType;
|
||||
final String itemId;
|
||||
final String data;
|
||||
final List<String> sourceProviders;
|
||||
|
||||
const BatchCacheItem({
|
||||
required this.itemType,
|
||||
required this.itemId,
|
||||
required this.data,
|
||||
this.sourceProviders = const [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
"english": "Englisch",
|
||||
"german": "Deutsch",
|
||||
"spanish": "Spanisch",
|
||||
"french": "Französisch",
|
||||
|
||||
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
|
||||
"sortAlphabetically": "Alphabetisch sortieren",
|
||||
@@ -294,5 +295,15 @@
|
||||
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
||||
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
|
||||
"holdToSync": "Gedrückt halten zum Sync",
|
||||
"swipeToAdjustVolume": "Wischen für Lautstärke"
|
||||
"swipeToAdjustVolume": "Wischen für Lautstärke",
|
||||
|
||||
"aboutAndSupport": "Über & Unterstützung",
|
||||
"supportDevelopment": "Entwicklung unterstützen",
|
||||
"ensembleIsUnofficial": "Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.",
|
||||
"sponsorOnGitHub": "Auf GitHub sponsoren",
|
||||
"buyMeAKoFi": "Kauf mir einen Ko-fi",
|
||||
"aboutEnsemble": "Über Ensemble",
|
||||
"version": "Version",
|
||||
"visitGitHubRepo": "GitHub-Repository besuchen",
|
||||
"reportABug": "Einen Fehler melden"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"description": "Hint for port field"
|
||||
},
|
||||
|
||||
"portDescription": "Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.",
|
||||
"portDescription": "Leave blank unless using a custom port",
|
||||
"@portDescription": {
|
||||
"description": "Description for port field"
|
||||
},
|
||||
@@ -221,6 +221,36 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"discoveryMixes": "Discovery Mixes",
|
||||
"@discoveryMixes": {
|
||||
"description": "Provider discovery mixes (e.g., Tidal playlists)"
|
||||
},
|
||||
|
||||
"discoveryMixesDescription": "Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen",
|
||||
"@discoveryMixesDescription": {
|
||||
"description": "Description for discovery mixes setting"
|
||||
},
|
||||
|
||||
"discoveryRows": "Discovery rows from Music Assistant",
|
||||
"@discoveryRows": {
|
||||
"description": "Label for discovery rows section"
|
||||
},
|
||||
|
||||
"refreshDiscoveryRows": "Refresh available rows",
|
||||
"@refreshDiscoveryRows": {
|
||||
"description": "Button to refresh discovery rows"
|
||||
},
|
||||
|
||||
"noDiscoveryRows": "No discovery rows available",
|
||||
"@noDiscoveryRows": {
|
||||
"description": "Empty state when no discovery rows available"
|
||||
},
|
||||
|
||||
"discoveryRowBadge": "Music Assistant",
|
||||
"@discoveryRowBadge": {
|
||||
"description": "Badge label for discovery rows"
|
||||
},
|
||||
|
||||
"continueListening": "Continue Listening",
|
||||
"@continueListening": {
|
||||
"description": "Home screen section title"
|
||||
@@ -261,6 +291,11 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoritePodcasts": "Favorite Podcasts",
|
||||
"@favoritePodcasts": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"searchMusic": "Search music...",
|
||||
"@searchMusic": {
|
||||
"description": "Search placeholder text"
|
||||
@@ -599,6 +634,11 @@
|
||||
|
||||
"tracksAddedToQueue": "Tracks added to queue",
|
||||
"@tracksAddedToQueue": {
|
||||
"description": "Snackbar message for tracks added to queue"
|
||||
},
|
||||
|
||||
"trackAddedToQueue": "Track added to queue",
|
||||
"@trackAddedToQueue": {
|
||||
"description": "Confirmation when tracks added"
|
||||
},
|
||||
|
||||
@@ -1055,10 +1095,19 @@
|
||||
"description": "Spanish language"
|
||||
},
|
||||
|
||||
"french": "French",
|
||||
"@french": {
|
||||
"description": "French language"
|
||||
},
|
||||
|
||||
"noTracksInPlaylist": "No tracks in playlist",
|
||||
"sortAlphabetically": "Sort alphabetically",
|
||||
"sortByYear": "Sort by year",
|
||||
"sortBySeriesOrder": "Sort by series order",
|
||||
"sortByDuration": "Sort by duration",
|
||||
"newestFirst": "Newest first",
|
||||
"oldestFirst": "Oldest first",
|
||||
"viewDetails": "View details",
|
||||
"listView": "List view",
|
||||
"gridView": "Grid view",
|
||||
"noBooksInSeries": "No books found in this series",
|
||||
@@ -1074,13 +1123,15 @@
|
||||
"showFavoriteTracks": "Show a row of your favorite tracks",
|
||||
"showFavoritePlaylists": "Show a row of your favorite playlists",
|
||||
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
|
||||
"showFavoritePodcasts": "Show a row of your favorite podcasts",
|
||||
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
|
||||
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
||||
"favorite": "Favorite",
|
||||
"addedToFavorites": "Added to favorites",
|
||||
"removedFromFavorites": "Removed from favorites",
|
||||
"addedToLibrary": "Added to library",
|
||||
"removedFromLibrary": "Removed from library",
|
||||
"addToLibrary": "Add to library",
|
||||
"addToLibrary": "Add",
|
||||
"unknown": "Unknown",
|
||||
"noUpcomingTracks": "No upcoming tracks",
|
||||
"showAll": "Show all",
|
||||
@@ -1129,6 +1180,10 @@
|
||||
"audiobookLibraries": "Audiobook Libraries",
|
||||
"chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include",
|
||||
"unknownLibrary": "Unknown Library",
|
||||
"musicProviders": "Music Providers",
|
||||
"musicProvidersDescription": "Choose which accounts to show in your library",
|
||||
"libraryRefreshing": "Refreshing library...",
|
||||
"cannotDisableLastProvider": "Cannot disable the last provider",
|
||||
"noSeriesFound": "No Series Found",
|
||||
"ensembleBugReport": "Ensemble Bug Report",
|
||||
"byOwner": "By {owner}",
|
||||
@@ -1192,11 +1247,21 @@
|
||||
"description": "Bottom sheet title for adding album to queue"
|
||||
},
|
||||
|
||||
"addToQueue": "Add to queue",
|
||||
"@addToQueue": {
|
||||
"description": "Add item to queue on selected player"
|
||||
},
|
||||
|
||||
"addToQueueOn": "Add to queue on...",
|
||||
"@addToQueueOn": {
|
||||
"description": "Bottom sheet title for adding to queue"
|
||||
},
|
||||
|
||||
"startRadio": "Start radio",
|
||||
"@startRadio": {
|
||||
"description": "Start radio on selected player"
|
||||
},
|
||||
|
||||
"startRadioOn": "Start {name} radio on...",
|
||||
"@startRadioOn": {
|
||||
"description": "Bottom sheet title for starting radio",
|
||||
@@ -1277,6 +1342,16 @@
|
||||
"description": "Description for smart sort players setting"
|
||||
},
|
||||
|
||||
"disableAutoSwitch": "Disable Auto-Switch",
|
||||
"@disableAutoSwitch": {
|
||||
"description": "Setting to disable automatic player switching"
|
||||
},
|
||||
|
||||
"disableAutoSwitchDescription": "Never automatically switch to a different player",
|
||||
"@disableAutoSwitchDescription": {
|
||||
"description": "Description for disable auto-switch setting"
|
||||
},
|
||||
|
||||
"playerStateUnavailable": "Unavailable",
|
||||
"@playerStateUnavailable": {
|
||||
"description": "Player state when unavailable"
|
||||
@@ -1332,6 +1407,31 @@
|
||||
"description": "Description for show hints toggle"
|
||||
},
|
||||
|
||||
"volumePrecisionMode": "Volume Precision Mode",
|
||||
"@volumePrecisionMode": {
|
||||
"description": "Setting for fine volume control"
|
||||
},
|
||||
|
||||
"volumePrecisionModeDescription": "Hold still while adjusting volume for fine control",
|
||||
"@volumePrecisionModeDescription": {
|
||||
"description": "Description for volume precision mode setting"
|
||||
},
|
||||
|
||||
"display": "Display",
|
||||
"@display": {
|
||||
"description": "Settings section title for display options"
|
||||
},
|
||||
|
||||
"showProviderIcons": "Show Provider Icons",
|
||||
"@showProviderIcons": {
|
||||
"description": "Toggle label for showing provider icons on album art"
|
||||
},
|
||||
|
||||
"showProviderIconsDescription": "Display provider logos on album art",
|
||||
"@showProviderIconsDescription": {
|
||||
"description": "Description for show provider icons toggle"
|
||||
},
|
||||
|
||||
"pullToSelectPlayers": "Pull to select players",
|
||||
"@pullToSelectPlayers": {
|
||||
"description": "Hint shown when mini player bounces"
|
||||
@@ -1390,5 +1490,283 @@
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"noTracks": "No tracks",
|
||||
"@noTracks": {
|
||||
"description": "Empty state title when no tracks exist in library"
|
||||
},
|
||||
|
||||
"addTracksHint": "Add some music to your library to see tracks here",
|
||||
"@addTracksHint": {
|
||||
"description": "Hint for adding tracks to library"
|
||||
},
|
||||
|
||||
"noFavoriteRadioStations": "No favorite radio stations",
|
||||
"@noFavoriteRadioStations": {
|
||||
"description": "Empty state when no favorite radio stations"
|
||||
},
|
||||
|
||||
"longPressRadioHint": "Long-press a station and tap the heart to add it to favorites",
|
||||
"@longPressRadioHint": {
|
||||
"description": "Hint for favoriting radio stations"
|
||||
},
|
||||
|
||||
"noFavoritePodcasts": "No favorite podcasts",
|
||||
"@noFavoritePodcasts": {
|
||||
"description": "Empty state when no favorite podcasts"
|
||||
},
|
||||
|
||||
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
|
||||
"@longPressPodcastHint": {
|
||||
"description": "Hint for favoriting podcasts"
|
||||
},
|
||||
|
||||
"failedToPlayRadioStation": "Failed to play radio station: {error}",
|
||||
"@failedToPlayRadioStation": {
|
||||
"description": "Error when playing radio station fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"itemAlreadyInLibrary": "Item is already in library",
|
||||
"@itemAlreadyInLibrary": {
|
||||
"description": "Message when trying to add item already in library"
|
||||
},
|
||||
|
||||
"failedToAddToLibrary": "Failed to add to library",
|
||||
"@failedToAddToLibrary": {
|
||||
"description": "Error when adding to library fails"
|
||||
},
|
||||
|
||||
"cannotFindLibraryId": "Cannot find library ID for removal",
|
||||
"@cannotFindLibraryId": {
|
||||
"description": "Error when library ID not found for removal"
|
||||
},
|
||||
|
||||
"failedToRemoveFromLibrary": "Failed to remove from library",
|
||||
"@failedToRemoveFromLibrary": {
|
||||
"description": "Error when removing from library fails"
|
||||
},
|
||||
|
||||
"failedToPlayAlbum": "Failed to play album: {error}",
|
||||
"@failedToPlayAlbum": {
|
||||
"description": "Error when playing album fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"playingOnPlayer": "Playing {name} on {playerName}",
|
||||
"@playingOnPlayer": {
|
||||
"description": "Status when playing item on specific player",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayPlaylist": "Failed to play playlist: {error}",
|
||||
"@failedToPlayPlaylist": {
|
||||
"description": "Error when playing playlist fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayAudiobook": "Failed to play audiobook: {error}",
|
||||
"@failedToPlayAudiobook": {
|
||||
"description": "Error when playing audiobook fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"noOtherPlayersAvailable": "No other players available",
|
||||
"@noOtherPlayersAvailable": {
|
||||
"description": "Message when no other players available for transfer"
|
||||
},
|
||||
|
||||
"transferQueue": "Transfer queue",
|
||||
"@transferQueue": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"clearQueue": "Clear queue",
|
||||
"@clearQueue": {
|
||||
"description": "Tooltip for clear queue button"
|
||||
},
|
||||
|
||||
"syncingPlayer": "Syncing {playerName}...",
|
||||
"@syncingPlayer": {
|
||||
"description": "Status when syncing player",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"switchPlayer": "Switch Player",
|
||||
"@switchPlayer": {
|
||||
"description": "Label for switch player action"
|
||||
},
|
||||
|
||||
"logsCopiedToClipboard": "Logs copied to clipboard",
|
||||
"@logsCopiedToClipboard": {
|
||||
"description": "Confirmation when logs copied"
|
||||
},
|
||||
|
||||
"auto": "Auto",
|
||||
"@auto": {
|
||||
"description": "Auto option for language selection"
|
||||
},
|
||||
|
||||
"transferQueueTo": "Transfer Queue To",
|
||||
"@transferQueueTo": {
|
||||
"description": "Title for transfer queue dialog"
|
||||
},
|
||||
|
||||
"queueTransferredTo": "Queue transferred to {playerName}",
|
||||
"@queueTransferredTo": {
|
||||
"description": "Success message when queue transferred",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToTransferQueue": "Failed to transfer queue: {error}",
|
||||
"@failedToTransferQueue": {
|
||||
"description": "Error when queue transfer fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToLoadQueue": "Failed to load queue: {error}",
|
||||
"@failedToLoadQueue": {
|
||||
"description": "Error when queue loading fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"transferTo": "Transfer to...",
|
||||
"@transferTo": {
|
||||
"description": "Label for transfer destination selection"
|
||||
},
|
||||
|
||||
"transferQueueToPlayer": "Transfer queue to another player",
|
||||
"@transferQueueToPlayer": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"selectPlayerToPlay": "Select a player",
|
||||
"@selectPlayerToPlay": {
|
||||
"description": "Generic hint for selecting a player in player selector"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayAlbum": "Select player to play album",
|
||||
"@selectPlayerToPlayAlbum": {
|
||||
"description": "Hint for selecting player to play album"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayPlaylist": "Select player to play playlist",
|
||||
"@selectPlayerToPlayPlaylist": {
|
||||
"description": "Hint for selecting player to play playlist"
|
||||
},
|
||||
|
||||
"selectPlayerForRadio": "Select player for radio",
|
||||
"@selectPlayerForRadio": {
|
||||
"description": "Hint for selecting player to start radio"
|
||||
},
|
||||
|
||||
"selectPlayerForEpisode": "Select player for episode",
|
||||
"@selectPlayerForEpisode": {
|
||||
"description": "Hint for selecting player to play episode"
|
||||
},
|
||||
|
||||
"selectPlayerForAudiobook": "Select player for audiobook",
|
||||
"@selectPlayerForAudiobook": {
|
||||
"description": "Hint for selecting player to play audiobook"
|
||||
},
|
||||
|
||||
"selectPlayerToAddToQueue": "Select player to add to queue",
|
||||
"@selectPlayerToAddToQueue": {
|
||||
"description": "Hint for selecting player to add to queue"
|
||||
},
|
||||
|
||||
"aboutAndSupport": "About & Support",
|
||||
"@aboutAndSupport": {
|
||||
"description": "About and support section title"
|
||||
},
|
||||
|
||||
"supportDevelopment": "Support Development",
|
||||
"@supportDevelopment": {
|
||||
"description": "Support development section"
|
||||
},
|
||||
|
||||
"ensembleIsUnofficial": "Ensemble is an unofficial, community-built client. Consider supporting its development.",
|
||||
"@ensembleIsUnofficial": {
|
||||
"description": "Description about Ensemble being unofficial"
|
||||
},
|
||||
|
||||
"sponsorOnGitHub": "Sponsor on GitHub",
|
||||
"@sponsorOnGitHub": {
|
||||
"description": "GitHub Sponsors button label"
|
||||
},
|
||||
|
||||
"buyMeAKoFi": "Buy me a Ko-fi",
|
||||
"@buyMeAKoFi": {
|
||||
"description": "Ko-fi donation button label"
|
||||
},
|
||||
|
||||
"aboutEnsemble": "About Ensemble",
|
||||
"@aboutEnsemble": {
|
||||
"description": "About Ensemble section"
|
||||
},
|
||||
|
||||
"version": "Version",
|
||||
"@version": {
|
||||
"description": "Version label"
|
||||
},
|
||||
|
||||
"visitGitHubRepo": "Visit GitHub Repository",
|
||||
"@visitGitHubRepo": {
|
||||
"description": "Link to GitHub repo"
|
||||
},
|
||||
|
||||
"reportABug": "Report a Bug",
|
||||
"@reportABug": {
|
||||
"description": "Link to report issues"
|
||||
},
|
||||
|
||||
"noRowsEnabled": "No rows enabled",
|
||||
"@noRowsEnabled": {
|
||||
"description": "Empty state when no home screen rows are enabled"
|
||||
},
|
||||
|
||||
"pullToRefreshHint": "Pull down to refresh or enable rows in settings",
|
||||
"@pullToRefreshHint": {
|
||||
"description": "Hint when home screen is empty"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"english": "Inglés",
|
||||
"german": "Alemán",
|
||||
"spanish": "Español",
|
||||
"french": "Francés",
|
||||
"noTracksInPlaylist": "No hay canciones en la lista",
|
||||
"sortAlphabetically": "Ordenar alfabéticamente",
|
||||
"sortByYear": "Ordenar por año",
|
||||
@@ -490,5 +491,15 @@
|
||||
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
||||
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
|
||||
"holdToSync": "Mantén presionado para sync",
|
||||
"swipeToAdjustVolume": "Desliza para ajustar volumen"
|
||||
"swipeToAdjustVolume": "Desliza para ajustar volumen",
|
||||
|
||||
"aboutAndSupport": "Acerca de y Soporte",
|
||||
"supportDevelopment": "Apoyar el Desarrollo",
|
||||
"ensembleIsUnofficial": "Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.",
|
||||
"sponsorOnGitHub": "Patrocina en GitHub",
|
||||
"buyMeAKoFi": "Cómprame un Ko-fi",
|
||||
"aboutEnsemble": "Acerca de Ensemble",
|
||||
"version": "Versión",
|
||||
"visitGitHubRepo": "Visitar Repositorio GitHub",
|
||||
"reportABug": "Reportar un Error"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -30,61 +31,90 @@ late MassivAudioHandler audioHandler;
|
||||
// Global debug logger instance
|
||||
final _logger = DebugLogger();
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
void main() {
|
||||
// All initialization must run inside the same zone as runApp() to avoid
|
||||
// "Zone mismatch" errors from Flutter's binding system
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize local database
|
||||
await DatabaseService.instance.initialize();
|
||||
_logger.log('💾 Database initialized');
|
||||
// Initialize SharedPreferences cache early for performance
|
||||
await SettingsService.initialize();
|
||||
_logger.log('⚡ SharedPreferences cached');
|
||||
|
||||
// Migrate existing ownerName to profile (one-time for existing users)
|
||||
await ProfileService.instance.migrateFromOwnerName();
|
||||
// Initialize local database
|
||||
await DatabaseService.instance.initialize();
|
||||
_logger.log('💾 Database initialized');
|
||||
|
||||
// Migrate credentials to secure storage (one-time for existing users)
|
||||
await SettingsService.migrateToSecureStorage();
|
||||
_logger.log('🔐 Secure storage migration complete');
|
||||
// Migrate existing ownerName to profile (one-time for existing users)
|
||||
await ProfileService.instance.migrateFromOwnerName();
|
||||
|
||||
// Load library from cache for instant startup
|
||||
await SyncService.instance.loadFromCache();
|
||||
_logger.log('📦 Library cache loaded');
|
||||
// Migrate credentials to secure storage (one-time for existing users)
|
||||
await SettingsService.migrateToSecureStorage();
|
||||
_logger.log('🔐 Secure storage migration complete');
|
||||
|
||||
// Create auth manager for streaming headers
|
||||
final authManager = AuthManager();
|
||||
// Load library from cache for instant startup
|
||||
await SyncService.instance.loadFromCache();
|
||||
_logger.log('📦 Library cache loaded');
|
||||
|
||||
// Initialize audio_service with our custom handler
|
||||
audioHandler = await AudioService.init(
|
||||
builder: () => MassivAudioHandler(authManager: authManager),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
|
||||
androidNotificationChannelName: 'Ensemble Audio',
|
||||
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
|
||||
androidNotificationIcon: 'drawable/ic_notification',
|
||||
androidShowNotificationBadge: false,
|
||||
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
||||
),
|
||||
// Create auth manager for streaming headers
|
||||
final authManager = AuthManager();
|
||||
|
||||
// Initialize audio_service with our custom handler
|
||||
audioHandler = await AudioService.init(
|
||||
builder: () => MassivAudioHandler(authManager: authManager),
|
||||
config: const AudioServiceConfig(
|
||||
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
|
||||
androidNotificationChannelName: 'Ensemble Audio',
|
||||
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
|
||||
androidNotificationIcon: 'drawable/ic_notification',
|
||||
androidShowNotificationBadge: false,
|
||||
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
||||
),
|
||||
);
|
||||
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
||||
|
||||
// Set preferred orientations
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
// Set initial system UI overlay style based on platform brightness
|
||||
// SystemUIWrapper will update this dynamically when theme changes
|
||||
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
final isDark = platformBrightness == Brightness.dark;
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
// Set up Flutter error boundary to catch and log widget build errors
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
|
||||
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
|
||||
// Still report to Flutter's default handler in debug mode
|
||||
FlutterError.presentError(details);
|
||||
};
|
||||
|
||||
// Catch errors from the platform dispatcher (platform channel errors)
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
|
||||
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
|
||||
return true; // Handled
|
||||
};
|
||||
|
||||
runApp(const MusicAssistantApp());
|
||||
},
|
||||
(error, stackTrace) {
|
||||
_logger.error('Uncaught async error: $error', context: 'ZoneError');
|
||||
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
|
||||
},
|
||||
);
|
||||
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
||||
|
||||
// Set preferred orientations
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
// Set initial system UI overlay style based on platform brightness
|
||||
// SystemUIWrapper will update this dynamically when theme changes
|
||||
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
final isDark = platformBrightness == Brightness.dark;
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
runApp(const MusicAssistantApp());
|
||||
}
|
||||
|
||||
class MusicAssistantApp extends StatefulWidget {
|
||||
@@ -101,6 +131,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
final _hardwareVolumeService = HardwareVolumeService();
|
||||
StreamSubscription? _volumeUpSub;
|
||||
StreamSubscription? _volumeDownSub;
|
||||
StreamSubscription? _absoluteVolumeSub;
|
||||
String? _lastSelectedPlayerId;
|
||||
String? _builtinPlayerId;
|
||||
|
||||
@@ -131,6 +162,12 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
|
||||
_adjustVolume(-_volumeStep);
|
||||
});
|
||||
|
||||
// Listen for absolute volume changes from lockscreen/background
|
||||
// (ContentObserver on system STREAM_MUSIC volume)
|
||||
_absoluteVolumeSub = _hardwareVolumeService.onAbsoluteVolumeChange.listen((volume) {
|
||||
_setAbsoluteVolume(volume);
|
||||
});
|
||||
} catch (e, stack) {
|
||||
_logger.error('Hardware volume init failed', context: 'VolumeInit', error: e, stackTrace: stack);
|
||||
}
|
||||
@@ -146,18 +183,41 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
}
|
||||
|
||||
/// Enable/disable volume button interception based on selected player.
|
||||
/// Also manages the volume observer for lockscreen volume routing.
|
||||
Future<void> _updateVolumeInterception() async {
|
||||
final player = _musicProvider.selectedPlayer;
|
||||
final isBuiltinPlayer = _builtinPlayerId != null &&
|
||||
_musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
|
||||
player?.playerId == _builtinPlayerId;
|
||||
await _hardwareVolumeService.setIntercepting(!isBuiltinPlayer);
|
||||
|
||||
// Start/stop volume observer for lockscreen volume changes.
|
||||
// When a remote/group player is active, observe system volume changes
|
||||
// so lockscreen hardware buttons route to the MA player.
|
||||
if (!isBuiltinPlayer && player != null) {
|
||||
// Get the effective volume to sync the system volume HUD
|
||||
final effectiveVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
|
||||
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
|
||||
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
|
||||
: player.volume;
|
||||
await _hardwareVolumeService.startVolumeObserver(initialVolume: effectiveVolume);
|
||||
} else {
|
||||
await _hardwareVolumeService.stopVolumeObserver();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _adjustVolume(int delta) async {
|
||||
final player = _musicProvider.selectedPlayer;
|
||||
if (player == null) return;
|
||||
|
||||
final newVolume = (player.volume + delta).clamp(0, 100);
|
||||
if (newVolume != player.volume) {
|
||||
// Use effective volume for group players (player.volume returns 0
|
||||
// because MA API returns null for group player volume_level)
|
||||
final currentVolume = _musicProvider.groupVolumeManager.isGroupPlayer(player)
|
||||
? (_musicProvider.groupVolumeManager.getEffectiveVolumeLevel(
|
||||
player, _musicProvider.availablePlayersUnfiltered) ?? player.volume)
|
||||
: player.volume;
|
||||
|
||||
final newVolume = (currentVolume + delta).clamp(0, 100);
|
||||
if (newVolume != currentVolume) {
|
||||
try {
|
||||
await _musicProvider.setVolume(player.playerId, newVolume);
|
||||
} catch (e) {
|
||||
@@ -166,11 +226,29 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle absolute volume change from lockscreen/background.
|
||||
/// Called when the ContentObserver detects a system volume change
|
||||
/// while a remote/group player is active.
|
||||
Future<void> _setAbsoluteVolume(int volume) async {
|
||||
final player = _musicProvider.selectedPlayer;
|
||||
if (player == null) return;
|
||||
|
||||
// Don't route to builtin player - that's handled by the system directly
|
||||
if (_builtinPlayerId != null && player.playerId == _builtinPlayerId) return;
|
||||
|
||||
try {
|
||||
await _musicProvider.setVolume(player.playerId, volume.clamp(0, 100));
|
||||
} catch (e) {
|
||||
_logger.error('Lockscreen volume change failed', context: 'VolumeControl', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_musicProvider.removeListener(_onProviderChanged);
|
||||
_volumeUpSub?.cancel();
|
||||
_volumeDownSub?.cancel();
|
||||
_absoluteVolumeSub?.cancel();
|
||||
_hardwareVolumeService.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
@@ -196,8 +274,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
}
|
||||
|
||||
// Then check expanded player
|
||||
// No haptic - Android back gesture provides its own
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
@@ -274,12 +353,44 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
],
|
||||
supportedLocales: S.supportedLocales,
|
||||
locale: localeProvider.locale,
|
||||
// Fallback to English when locale is not supported
|
||||
localeListResolutionCallback: (locales, supportedLocales) {
|
||||
// If user has set a specific locale, try to use it
|
||||
if (localeProvider.locale != null) {
|
||||
for (final supported in supportedLocales) {
|
||||
if (supported.languageCode == localeProvider.locale!.languageCode) {
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try to match system locales
|
||||
if (locales != null) {
|
||||
for (final locale in locales) {
|
||||
for (final supported in supportedLocales) {
|
||||
if (supported.languageCode == locale.languageCode) {
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to English (not German)
|
||||
return const Locale('en');
|
||||
},
|
||||
themeMode: themeProvider.themeMode,
|
||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
builder: (context, child) {
|
||||
// Wrap entire app with global player overlay
|
||||
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
||||
// Wrap entire app with global player overlay.
|
||||
// Provide an Overlay ancestor so BottomNavigationBar/Tooltip can render safely.
|
||||
return Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (context) => GlobalPlayerOverlay(
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
home: const AppStartup(),
|
||||
),
|
||||
@@ -424,6 +535,9 @@ class _AppStartupState extends State<AppStartup> {
|
||||
Future<void> _checkAndConnect() async {
|
||||
final serverUrl = await SettingsService.getServerUrl();
|
||||
|
||||
// DEBUG: Log where server URL is coming from
|
||||
_logger.log('🐛 DEBUG: Server URL from SharedPreferences: $serverUrl');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
@@ -432,6 +546,7 @@ class _AppStartupState extends State<AppStartup> {
|
||||
|
||||
// If we have a saved server URL, attempt auto-connection
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
_logger.log('🐛 DEBUG: Auto-connecting because server URL exists');
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
});
|
||||
|
||||
@@ -16,6 +16,10 @@ class ProviderMapping {
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
/// Whether this item is in the library for this specific provider instance.
|
||||
/// True = this provider "owns" the item (user added it from this account)
|
||||
/// False = item exists but wasn't added from this provider
|
||||
final bool inLibrary;
|
||||
|
||||
ProviderMapping({
|
||||
required this.itemId,
|
||||
@@ -23,6 +27,7 @@ class ProviderMapping {
|
||||
required this.providerInstance,
|
||||
required this.available,
|
||||
this.audioFormat,
|
||||
this.inLibrary = true,
|
||||
});
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
@@ -32,6 +37,8 @@ class ProviderMapping {
|
||||
providerInstance: json['provider_instance'] as String? ?? '',
|
||||
available: json['available'] as bool? ?? true,
|
||||
audioFormat: json['audio_format'] as Map<String, dynamic>?,
|
||||
// Parse in_library field - MA uses 1/0 or true/false
|
||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +49,7 @@ class ProviderMapping {
|
||||
'provider_instance': providerInstance,
|
||||
'available': available,
|
||||
if (audioFormat != null) 'audio_format': audioFormat,
|
||||
'in_library': inLibrary,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -118,6 +126,23 @@ class MediaItem {
|
||||
if (duration != null) 'duration': duration!.inSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MediaItem &&
|
||||
runtimeType == other.runtimeType &&
|
||||
itemId == other.itemId &&
|
||||
provider == other.provider;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(itemId, provider);
|
||||
|
||||
/// Check if this item is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
class Artist extends MediaItem {
|
||||
@@ -146,6 +171,12 @@ class Artist extends MediaItem {
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this artist is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => super.toJson();
|
||||
}
|
||||
@@ -267,6 +298,12 @@ class Track extends MediaItem {
|
||||
String get artistsString =>
|
||||
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
|
||||
|
||||
/// Check if this track is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = super.toJson();
|
||||
@@ -313,6 +350,12 @@ class Playlist extends MediaItem {
|
||||
favorite: item.favorite,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this playlist is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
class Chapter {
|
||||
@@ -421,9 +464,20 @@ class Audiobook extends MediaItem {
|
||||
factory Audiobook.fromJson(Map<String, dynamic> json) {
|
||||
final item = MediaItem.fromJson(json);
|
||||
|
||||
// Parse year
|
||||
// Parse year - check top level first, then metadata.release_date (ABS format)
|
||||
int? year;
|
||||
final yearValue = json['year'];
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
dynamic yearValue = json['year'];
|
||||
if (yearValue == null && metadata != null) {
|
||||
// ABS stores year in metadata.release_date (can be "2024" or "2024-01-15")
|
||||
final releaseDate = metadata['release_date'];
|
||||
if (releaseDate is String && releaseDate.isNotEmpty) {
|
||||
// Extract year from date string (handles both "2024" and "2024-01-15" formats)
|
||||
yearValue = releaseDate.split('-').first;
|
||||
} else if (releaseDate is int) {
|
||||
yearValue = releaseDate;
|
||||
}
|
||||
}
|
||||
if (yearValue is int) {
|
||||
year = yearValue;
|
||||
} else if (yearValue is String) {
|
||||
@@ -453,8 +507,6 @@ class Audiobook extends MediaItem {
|
||||
|
||||
// Parse chapters - check top level first, then metadata
|
||||
List<Chapter>? chapters;
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
|
||||
dynamic chaptersData = json['chapters'];
|
||||
if (chaptersData == null && metadata != null) {
|
||||
chaptersData = metadata['chapters'];
|
||||
@@ -500,6 +552,12 @@ class Audiobook extends MediaItem {
|
||||
String get narratorsString =>
|
||||
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
|
||||
|
||||
/// Check if this audiobook is in the user's library
|
||||
bool get inLibrary {
|
||||
if (provider == 'library') return true;
|
||||
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
/// Get progress as a percentage (0.0 to 1.0)
|
||||
double get progress {
|
||||
if (fullyPlayed == true) return 1.0;
|
||||
|
||||
@@ -17,6 +17,7 @@ class Player {
|
||||
final String? activeSource; // The currently active source for this player
|
||||
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
|
||||
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
|
||||
final String? activeQueue; // The active_queue from MA - has value when MA is controlling playback
|
||||
|
||||
Player({
|
||||
required this.playerId,
|
||||
@@ -35,6 +36,7 @@ class Player {
|
||||
this.activeSource,
|
||||
this.isExternalSource = false,
|
||||
this.appId,
|
||||
this.activeQueue,
|
||||
});
|
||||
|
||||
/// Create a copy of this Player with some fields replaced
|
||||
@@ -55,6 +57,7 @@ class Player {
|
||||
String? activeSource,
|
||||
bool? isExternalSource,
|
||||
String? appId,
|
||||
String? activeQueue,
|
||||
}) {
|
||||
return Player(
|
||||
playerId: playerId ?? this.playerId,
|
||||
@@ -73,6 +76,7 @@ class Player {
|
||||
activeSource: activeSource ?? this.activeSource,
|
||||
isExternalSource: isExternalSource ?? this.isExternalSource,
|
||||
appId: appId ?? this.appId,
|
||||
activeQueue: activeQueue ?? this.activeQueue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,50 +222,61 @@ class Player {
|
||||
// Parse active_source - the currently active source for this player
|
||||
final activeSource = json['active_source'] as String?;
|
||||
|
||||
// Detect external source (optical, Spotify, AirPlay, etc.)
|
||||
// Primary indicator: app_id - if not 'music_assistant', it's an external source
|
||||
// Secondary indicators: URI patterns and media_type
|
||||
// Parse active_queue - this is the key indicator for MA-controlled playback
|
||||
// When MA is playing: active_queue = 'uuid:...' or similar queue ID
|
||||
// When external source: active_queue = null
|
||||
final activeQueue = json['active_queue'] as String?;
|
||||
|
||||
// Parse app_id - helps distinguish external sources on DLNA players
|
||||
final appId = json['app_id'] as String?;
|
||||
|
||||
// Detect external source (optical, Spotify Connect, AirPlay, etc.)
|
||||
//
|
||||
// Detection logic priority:
|
||||
// 1. If app_id == 'music_assistant' -> MA is playing, NOT external
|
||||
// 2. If active_queue has a value -> MA has a queue, NOT external
|
||||
// (This handles Spotify as music provider where URI is spotify:// but MA controls playback)
|
||||
// 3. If app_id is set and NOT 'music_assistant' -> external source (e.g., 'http', 'spotify')
|
||||
// 4. Fallback: Check URI patterns for simple external IDs (optical, line_in, etc.)
|
||||
bool isExternalSource = false;
|
||||
|
||||
// Check app_id first - this is the most reliable indicator for DLNA players
|
||||
// When MA is playing: app_id = 'music_assistant'
|
||||
// When external source: app_id = 'http', 'spotify', etc.
|
||||
final appId = json['app_id'] as String?;
|
||||
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
|
||||
// app_id is set but not 'music_assistant' - external source is active
|
||||
// If MA explicitly says it's playing, trust that
|
||||
if (appId == 'music_assistant') {
|
||||
isExternalSource = false;
|
||||
}
|
||||
// If there's an active queue, MA is controlling playback (even with spotify:// URIs)
|
||||
else if (activeQueue != null && activeQueue.isNotEmpty) {
|
||||
isExternalSource = false;
|
||||
}
|
||||
// If app_id is set to something else (http, spotify, etc.), it's external
|
||||
else if (appId != null && appId.isNotEmpty) {
|
||||
isExternalSource = true;
|
||||
}
|
||||
|
||||
// Also check current_media for additional external source indicators
|
||||
if (!isExternalSource && json.containsKey('current_media')) {
|
||||
// Fallback: check current_media for simple external source indicators
|
||||
// Only check these when we have no app_id and no active_queue
|
||||
else if (json.containsKey('current_media')) {
|
||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||
if (currentMedia != null) {
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
|
||||
// External source indicators:
|
||||
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
|
||||
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
|
||||
// 3. Media type is 'unknown' (MA doesn't recognize the source)
|
||||
// Only check for simple external source identifiers (physical inputs)
|
||||
// Do NOT flag spotify://, airplay://, etc. as external here -
|
||||
// those should only be external if app_id indicates external control
|
||||
if (uri != null) {
|
||||
final uriLower = uri.toLowerCase();
|
||||
// Simple external source identifiers (no :// or /)
|
||||
// These are physical inputs that are always external
|
||||
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
|
||||
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
|
||||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
|
||||
uriLower == 'coaxial' || uriLower == 'toslink');
|
||||
// External streaming protocols
|
||||
final isExternalProtocol = uriLower.startsWith('spotify://') ||
|
||||
uriLower.startsWith('airplay://') ||
|
||||
uriLower.startsWith('cast://') ||
|
||||
uriLower.startsWith('bluetooth://');
|
||||
|
||||
isExternalSource = isSimpleExternalId || isExternalProtocol;
|
||||
isExternalSource = isSimpleExternalId;
|
||||
}
|
||||
|
||||
// Also check media_type - 'unknown' often indicates external source
|
||||
// Also check media_type - 'unknown' with non-MA URI indicates external source
|
||||
if (!isExternalSource && mediaType == 'unknown') {
|
||||
// If media_type is unknown and URI doesn't look like MA content, it's external
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
|
||||
isExternalSource = true;
|
||||
@@ -287,6 +302,7 @@ class Player {
|
||||
activeSource: activeSource,
|
||||
isExternalSource: isExternalSource,
|
||||
appId: appId,
|
||||
activeQueue: activeQueue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,6 +324,7 @@ class Player {
|
||||
if (activeSource != null) 'active_source': activeSource,
|
||||
'is_external_source': isExternalSource,
|
||||
if (appId != null) 'app_id': appId,
|
||||
if (activeQueue != null) 'active_queue': activeQueue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/// Represents a music provider instance from Music Assistant.
|
||||
///
|
||||
/// Each provider (like Spotify, Tidal) can have multiple instances
|
||||
/// (e.g., two different Spotify accounts). This class represents
|
||||
/// a single provider instance with its unique ID.
|
||||
class ProviderInstance {
|
||||
/// Unique identifier for this provider instance (e.g., "spotify--abc123")
|
||||
final String instanceId;
|
||||
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this instance (e.g., "Spotify (John's Account)")
|
||||
final String name;
|
||||
|
||||
/// Whether this provider is currently available/connected
|
||||
final bool available;
|
||||
|
||||
ProviderInstance({
|
||||
required this.instanceId,
|
||||
required this.domain,
|
||||
required this.name,
|
||||
required this.available,
|
||||
});
|
||||
|
||||
/// Provider capabilities - maps domain to supported content types
|
||||
/// This determines which tabs/categories show which providers
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services (support artists, albums, tracks, playlists)
|
||||
// Spotify also has audiobooks and podcasts
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Self-hosted media servers (can have music, audiobooks, and podcasts)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Audiobook and podcast providers
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'},
|
||||
// Radio providers (tunein also has podcasts)
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
'radiobrowser': {'radio'},
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
|
||||
/// Known music provider domains that provide library content
|
||||
static const Set<String> musicProviderDomains = {
|
||||
// Streaming services
|
||||
'spotify',
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'deezer',
|
||||
'ytmusic',
|
||||
'soundcloud',
|
||||
'apple_music',
|
||||
'amazon_music',
|
||||
// Self-hosted / local
|
||||
'plex',
|
||||
'subsonic',
|
||||
'opensubsonic',
|
||||
'jellyfin',
|
||||
'emby',
|
||||
'navidrome',
|
||||
'audiobookshelf',
|
||||
'filesystem',
|
||||
'itunes_podcasts',
|
||||
// Other
|
||||
'tunein',
|
||||
'radiobrowser',
|
||||
'snapcast',
|
||||
'fully_kiosk',
|
||||
};
|
||||
|
||||
/// Whether this is a music provider (vs. player provider, metadata provider, etc.)
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
|
||||
/// Get supported content types for this provider
|
||||
Set<String> get supportedContentTypes =>
|
||||
providerCapabilities[domain] ?? <String>{};
|
||||
|
||||
/// Check if this provider supports a specific content type/category
|
||||
bool supportsContentType(String category) =>
|
||||
supportedContentTypes.contains(category);
|
||||
|
||||
factory ProviderInstance.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderInstance(
|
||||
instanceId: json['instance_id'] as String? ?? '',
|
||||
domain: json['domain'] as String? ?? json['type'] as String? ?? '',
|
||||
name: json['name'] as String? ?? json['instance_id'] as String? ?? 'Unknown',
|
||||
available: json['available'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'instance_id': instanceId,
|
||||
'domain': domain,
|
||||
'name': name,
|
||||
'available': available,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderInstance($instanceId: $name)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProviderInstance &&
|
||||
runtimeType == other.runtimeType &&
|
||||
instanceId == other.instanceId;
|
||||
|
||||
@override
|
||||
int get hashCode => instanceId.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Represents a provider manifest from Music Assistant.
|
||||
///
|
||||
/// Contains metadata about a provider type including its icons.
|
||||
/// This is separate from ProviderInstance which represents a configured
|
||||
/// instance of a provider.
|
||||
class ProviderManifest {
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this provider type
|
||||
final String name;
|
||||
|
||||
/// Description of the provider
|
||||
final String? description;
|
||||
|
||||
/// SVG icon as XML string
|
||||
final String? iconSvg;
|
||||
|
||||
/// SVG icon for dark theme as XML string
|
||||
final String? iconSvgDark;
|
||||
|
||||
/// Monochrome SVG icon as XML string
|
||||
final String? iconSvgMonochrome;
|
||||
|
||||
/// Material Design icon name (fallback)
|
||||
final String? icon;
|
||||
|
||||
ProviderManifest({
|
||||
required this.domain,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.iconSvg,
|
||||
this.iconSvgDark,
|
||||
this.iconSvgMonochrome,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
/// Whether this manifest has any SVG icon available
|
||||
bool get hasSvgIcon =>
|
||||
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
|
||||
|
||||
/// Get the best available SVG icon for the given brightness
|
||||
/// Prefers dark icon for dark themes, falls back to regular or monochrome
|
||||
String? getSvgIcon({bool isDark = false}) {
|
||||
if (isDark && iconSvgDark != null) {
|
||||
return iconSvgDark;
|
||||
}
|
||||
return iconSvg ?? iconSvgMonochrome;
|
||||
}
|
||||
|
||||
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderManifest(
|
||||
domain: json['domain'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
iconSvg: json['icon_svg'] as String?,
|
||||
iconSvgDark: json['icon_svg_dark'] as String?,
|
||||
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
|
||||
icon: json['icon'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderManifest($domain: $name)';
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'media_item.dart';
|
||||
|
||||
class RecommendationFolder {
|
||||
final String itemId;
|
||||
final String provider;
|
||||
final String name;
|
||||
final String? uri;
|
||||
final List<MediaItem> items;
|
||||
|
||||
RecommendationFolder({
|
||||
required this.itemId,
|
||||
required this.provider,
|
||||
required this.name,
|
||||
this.uri,
|
||||
required this.items,
|
||||
});
|
||||
|
||||
/// Unique ID for use in row ordering and settings
|
||||
/// Uses 'discovery:' prefix to distinguish from static row IDs
|
||||
String get rowId => 'discovery:$itemId';
|
||||
|
||||
factory RecommendationFolder.fromJson(Map<String, dynamic> json) {
|
||||
final itemsJson = json['items'] as List<dynamic>?;
|
||||
final parsedItems = itemsJson
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.map(_parseMediaItemJson)
|
||||
.toList() ?? <MediaItem>[];
|
||||
|
||||
return RecommendationFolder(
|
||||
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
|
||||
provider: json['provider'] as String? ?? 'unknown',
|
||||
name: json['name'] as String? ?? '',
|
||||
uri: json['uri'] as String?,
|
||||
items: parsedItems,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'item_id': itemId,
|
||||
'provider': provider,
|
||||
'name': name,
|
||||
if (uri != null) 'uri': uri,
|
||||
'items': items.map((item) => item.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Polymorphic parser for media items in recommendation folders
|
||||
/// Handles heterogeneous items (playlists, radio, etc.)
|
||||
MediaItem _parseMediaItemJson(Map<String, dynamic> json) {
|
||||
final mediaType = json['media_type'] as String? ?? '';
|
||||
|
||||
switch (mediaType) {
|
||||
case 'playlist':
|
||||
return Playlist.fromJson(json);
|
||||
case 'album':
|
||||
return Album.fromJson(json);
|
||||
case 'artist':
|
||||
return Artist.fromJson(json);
|
||||
case 'track':
|
||||
return Track.fromJson(json);
|
||||
case 'audiobook':
|
||||
return Audiobook.fromJson(json);
|
||||
default:
|
||||
// For radio, podcast, podcast_episode - use MediaItem base class
|
||||
return MediaItem.fromJson(json);
|
||||
}
|
||||
}
|
||||
@@ -2,168 +2,80 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/profile_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
import '../services/cache_service.dart';
|
||||
|
||||
/// Provider for managing connection state to Music Assistant server
|
||||
/// Handles WebSocket connection, authentication, and reconnection
|
||||
/// Callback types for connection events
|
||||
typedef OnConnected = Future<void> Function();
|
||||
typedef OnAuthenticated = Future<void> Function();
|
||||
typedef OnDisconnected = void Function();
|
||||
|
||||
/// Provider for connection state management.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to handle:
|
||||
/// - WebSocket connection lifecycle
|
||||
/// - Authentication flow (MA token, credentials)
|
||||
/// - Reconnection logic
|
||||
/// - Connection state broadcasting
|
||||
///
|
||||
/// Note: This is a partial extraction. Full integration requires updating
|
||||
/// all consumers to use this provider directly.
|
||||
class ConnectionProvider with ChangeNotifier {
|
||||
MusicAssistantAPI? _api;
|
||||
final AuthManager _authManager = AuthManager();
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
final CacheService _cacheService = CacheService();
|
||||
final AuthManager _authManager = AuthManager();
|
||||
|
||||
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
||||
MusicAssistantAPI? _api;
|
||||
String? _serverUrl;
|
||||
String? _error;
|
||||
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
||||
|
||||
// Callbacks for post-connection initialization
|
||||
VoidCallback? onConnected;
|
||||
VoidCallback? onAuthenticated;
|
||||
VoidCallback? onDisconnected;
|
||||
StreamSubscription? _connectionStateSubscription;
|
||||
|
||||
MAConnectionState get connectionState => _connectionState;
|
||||
String? get serverUrl => _serverUrl;
|
||||
String? get error => _error;
|
||||
bool get isConnected => _connectionState == MAConnectionState.connected ||
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
// Callbacks for connection events - set by the facade provider
|
||||
OnConnected? onConnected;
|
||||
OnAuthenticated? onAuthenticated;
|
||||
OnDisconnected? onDisconnected;
|
||||
|
||||
// User settings captured during authentication
|
||||
List<String> _providerFilter = [];
|
||||
List<String> _playerFilter = [];
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS
|
||||
// ============================================================================
|
||||
|
||||
MusicAssistantAPI? get api => _api;
|
||||
AuthManager get authManager => _authManager;
|
||||
CacheService get cacheService => _cacheService;
|
||||
String? get serverUrl => _serverUrl;
|
||||
String? get error => _error;
|
||||
MAConnectionState get connectionState => _connectionState;
|
||||
|
||||
ConnectionProvider() {
|
||||
_initialize();
|
||||
}
|
||||
bool get isConnected =>
|
||||
_connectionState == MAConnectionState.connected ||
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
|
||||
Future<void> _initialize() async {
|
||||
_serverUrl = await SettingsService.getServerUrl();
|
||||
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
|
||||
await _restoreAuthCredentials();
|
||||
await connectToServer(_serverUrl!);
|
||||
}
|
||||
}
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
|
||||
/// Restore auth credentials from persistent storage
|
||||
Future<void> _restoreAuthCredentials() async {
|
||||
final savedCredentials = await SettingsService.getAuthCredentials();
|
||||
if (savedCredentials != null) {
|
||||
_logger.log('🔐 Restoring saved auth credentials...');
|
||||
_authManager.deserializeCredentials(savedCredentials);
|
||||
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
|
||||
} else {
|
||||
_logger.log('🔐 No saved auth credentials found');
|
||||
}
|
||||
}
|
||||
// ============================================================================
|
||||
// CONNECTION
|
||||
// ============================================================================
|
||||
|
||||
/// Handle Music Assistant native authentication after WebSocket connection
|
||||
Future<bool> handleMaAuthentication() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
// First, try stored MA token
|
||||
final storedToken = await SettingsService.getMaAuthToken();
|
||||
if (storedToken != null) {
|
||||
_logger.log('🔐 Trying stored MA token...');
|
||||
final success = await _api!.authenticateWithToken(storedToken);
|
||||
if (success) {
|
||||
_logger.log('✅ MA authentication with stored token successful');
|
||||
await _fetchAndSetUserProfileName();
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
||||
await SettingsService.clearMaAuthToken();
|
||||
}
|
||||
|
||||
// No valid token - try stored credentials
|
||||
final username = await SettingsService.getUsername();
|
||||
final password = await SettingsService.getPassword();
|
||||
|
||||
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
|
||||
_logger.log('🔐 Trying stored credentials...');
|
||||
final accessToken = await _api!.loginWithCredentials(username, password);
|
||||
|
||||
if (accessToken != null) {
|
||||
_logger.log('✅ MA login with stored credentials successful');
|
||||
|
||||
final longLivedToken = await _api!.createLongLivedToken();
|
||||
if (longLivedToken != null) {
|
||||
await SettingsService.setMaAuthToken(longLivedToken);
|
||||
_logger.log('✅ Saved new long-lived MA token');
|
||||
} else {
|
||||
await SettingsService.setMaAuthToken(accessToken);
|
||||
}
|
||||
|
||||
await _fetchAndSetUserProfileName();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_logger.log('❌ MA authentication error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch user profile from MA and set owner name + create/activate local profile
|
||||
Future<void> _fetchAndSetUserProfileName() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final userInfo = await _api!.getCurrentUserInfo();
|
||||
if (userInfo == null) {
|
||||
_logger.log('⚠️ Could not fetch user profile');
|
||||
return;
|
||||
}
|
||||
|
||||
final displayName = userInfo['display_name'] as String?;
|
||||
final username = userInfo['username'] as String?;
|
||||
|
||||
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
|
||||
|
||||
final profileName = (displayName != null && displayName.isNotEmpty)
|
||||
? displayName
|
||||
: username;
|
||||
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
await SettingsService.setOwnerName(profileName);
|
||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||
|
||||
// Create/activate profile in local database
|
||||
if (DatabaseService.instance.isInitialized && username != null) {
|
||||
await ProfileService.instance.onMaAuthenticated(
|
||||
username: username,
|
||||
displayName: displayName,
|
||||
);
|
||||
_logger.log('✅ Local profile activated: $username');
|
||||
}
|
||||
} else {
|
||||
_logger.log('⚠️ No valid name in profile');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connectToServer(String serverUrl) async {
|
||||
/// Connect to the Music Assistant server
|
||||
Future<void> connect(String serverUrl) async {
|
||||
try {
|
||||
_error = null;
|
||||
_serverUrl = serverUrl;
|
||||
await SettingsService.setServerUrl(serverUrl);
|
||||
|
||||
// Disconnect existing connection
|
||||
await _api?.disconnect();
|
||||
// Dispose the old API to stop any pending reconnects
|
||||
_api?.dispose();
|
||||
|
||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||
|
||||
// Listen to connection state changes
|
||||
_api!.connectionState.listen(
|
||||
_connectionStateSubscription?.cancel();
|
||||
_connectionStateSubscription = _api!.connectionState.listen(
|
||||
(state) async {
|
||||
_connectionState = state;
|
||||
notifyListeners();
|
||||
@@ -173,9 +85,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
if (_api!.authRequired && !_api!.isAuthenticated) {
|
||||
_logger.log('🔐 MA auth required, attempting authentication...');
|
||||
final authenticated = await handleMaAuthentication();
|
||||
final authenticated = await _handleAuthentication();
|
||||
if (!authenticated) {
|
||||
_logger.log('❌ MA authentication failed - stopping connection flow');
|
||||
_error = 'Authentication required. Please log in again.';
|
||||
notifyListeners();
|
||||
return;
|
||||
@@ -183,15 +94,13 @@ class ConnectionProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// No auth required - notify connected
|
||||
onConnected?.call();
|
||||
// No auth required, trigger callback
|
||||
await onConnected?.call();
|
||||
} else if (state == MAConnectionState.authenticated) {
|
||||
_logger.log('✅ MA authentication successful');
|
||||
onAuthenticated?.call();
|
||||
await onAuthenticated?.call();
|
||||
} else if (state == MAConnectionState.disconnected) {
|
||||
// DON'T clear caches on disconnect - keep showing cached data
|
||||
// This allows instant UI display when app resumes or reconnects
|
||||
// Caches will be refreshed when connection is restored
|
||||
_logger.log('📡 Disconnected - keeping cached data for instant resume');
|
||||
onDisconnected?.call();
|
||||
}
|
||||
},
|
||||
@@ -214,44 +123,122 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _handleAuthentication() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
final storedToken = await SettingsService.getMaAuthToken();
|
||||
if (storedToken != null) {
|
||||
_logger.log('🔐 Trying stored MA token...');
|
||||
final success = await _api!.authenticateWithToken(storedToken);
|
||||
if (success) {
|
||||
_logger.log('✅ MA authentication with stored token successful');
|
||||
await _fetchUserSettings();
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
||||
await SettingsService.clearMaAuthToken();
|
||||
}
|
||||
|
||||
final username = await SettingsService.getUsername();
|
||||
final password = await SettingsService.getPassword();
|
||||
|
||||
if (username != null &&
|
||||
password != null &&
|
||||
username.isNotEmpty &&
|
||||
password.isNotEmpty) {
|
||||
_logger.log('🔐 Trying stored credentials...');
|
||||
final accessToken = await _api!.loginWithCredentials(username, password);
|
||||
|
||||
if (accessToken != null) {
|
||||
_logger.log('✅ MA login with stored credentials successful');
|
||||
|
||||
final longLivedToken = await _api!.createLongLivedToken();
|
||||
if (longLivedToken != null) {
|
||||
await SettingsService.setMaAuthToken(longLivedToken);
|
||||
_logger.log('✅ Saved new long-lived MA token');
|
||||
} else {
|
||||
await SettingsService.setMaAuthToken(accessToken);
|
||||
}
|
||||
|
||||
await _fetchUserSettings();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_logger.log('❌ MA authentication error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchUserSettings() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final userInfo = await _api!.getCurrentUserInfo();
|
||||
if (userInfo == null) return;
|
||||
|
||||
// Set profile name
|
||||
final displayName = userInfo['display_name'] as String?;
|
||||
final username = userInfo['username'] as String?;
|
||||
final profileName =
|
||||
(displayName != null && displayName.isNotEmpty) ? displayName : username;
|
||||
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
await SettingsService.setOwnerName(profileName);
|
||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||
}
|
||||
|
||||
// Capture provider filter
|
||||
final providerFilterRaw = userInfo['provider_filter'];
|
||||
if (providerFilterRaw is List) {
|
||||
_providerFilter = providerFilterRaw.cast<String>().toList();
|
||||
if (_providerFilter.isNotEmpty) {
|
||||
_logger.log(
|
||||
'🔒 Provider filter active: ${_providerFilter.length} providers allowed');
|
||||
}
|
||||
} else {
|
||||
_providerFilter = [];
|
||||
}
|
||||
|
||||
// Capture player filter
|
||||
final playerFilterRaw = userInfo['player_filter'];
|
||||
if (playerFilterRaw is List) {
|
||||
_playerFilter = playerFilterRaw.cast<String>().toList();
|
||||
if (_playerFilter.isNotEmpty) {
|
||||
_logger.log(
|
||||
'🔒 Player filter active: ${_playerFilter.length} players allowed');
|
||||
}
|
||||
} else {
|
||||
_playerFilter = [];
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from the server
|
||||
Future<void> disconnect() async {
|
||||
_connectionStateSubscription?.cancel();
|
||||
await _api?.disconnect();
|
||||
_connectionState = MAConnectionState.disconnected;
|
||||
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all caches and state (for logout or server change)
|
||||
void clearCachesOnLogout() {
|
||||
_cacheService.clearAll();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check connection and reconnect if needed (called when app resumes)
|
||||
/// Check connection and reconnect if needed
|
||||
Future<void> checkAndReconnect() async {
|
||||
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
|
||||
|
||||
if (_serverUrl == null) {
|
||||
_logger.log('🔄 No server URL saved, skipping reconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_connectionState != MAConnectionState.connected &&
|
||||
_connectionState != MAConnectionState.authenticated) {
|
||||
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
|
||||
try {
|
||||
await connectToServer(_serverUrl!);
|
||||
_logger.log('🔄 Reconnection successful');
|
||||
} catch (e) {
|
||||
_logger.log('🔄 Reconnection failed: $e');
|
||||
}
|
||||
} else {
|
||||
_logger.log('🔄 Already connected, connection verified');
|
||||
if (_serverUrl != null && !isConnected) {
|
||||
_logger.log('🔄 Attempting reconnection...');
|
||||
await connect(_serverUrl!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectionStateSubscription?.cancel();
|
||||
_api?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
|
||||
/// Provider for managing library data (artists, albums, tracks, playlists)
|
||||
class LibraryProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
final CacheService _cacheService;
|
||||
|
||||
MusicAssistantAPI? _api;
|
||||
|
||||
List<Artist> _artists = [];
|
||||
List<Album> _albums = [];
|
||||
List<Track> _tracks = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
// Search state persistence
|
||||
String _lastSearchQuery = '';
|
||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
|
||||
// Getters
|
||||
List<Artist> get artists => _artists;
|
||||
List<Album> get albums => _albums;
|
||||
List<Track> get tracks => _tracks;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
String get lastSearchQuery => _lastSearchQuery;
|
||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||
|
||||
LibraryProvider(this._cacheService);
|
||||
|
||||
/// Initialize with API reference after connection
|
||||
void initialize(MusicAssistantAPI api) {
|
||||
_api = api;
|
||||
}
|
||||
|
||||
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||
_lastSearchQuery = query;
|
||||
_lastSearchResults = results;
|
||||
}
|
||||
|
||||
void clearSearchState() {
|
||||
_lastSearchQuery = '';
|
||||
_lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOME SCREEN DATA
|
||||
// ============================================================================
|
||||
|
||||
/// Get recently played albums with caching
|
||||
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached recent albums');
|
||||
return _cacheService.getCachedRecentAlbums()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh recent albums...');
|
||||
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedRecentAlbums(albums);
|
||||
return albums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch recent albums: $e');
|
||||
return _cacheService.getCachedRecentAlbums() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get discover artists (random) with caching
|
||||
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover artists');
|
||||
return _cacheService.getCachedDiscoverArtists()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover artists...');
|
||||
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverArtists(artists);
|
||||
return artists;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover artists: $e');
|
||||
return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get discover albums (random) with caching
|
||||
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover albums');
|
||||
return _cacheService.getCachedDiscoverAlbums()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover albums...');
|
||||
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverAlbums(albums);
|
||||
return albums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover albums: $e');
|
||||
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||
void invalidateHomeCache() {
|
||||
_cacheService.invalidateHomeCache();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LIBRARY DATA
|
||||
// ============================================================================
|
||||
|
||||
Future<void> loadLibrary() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
||||
|
||||
final results = await Future.wait([
|
||||
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
||||
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
||||
]);
|
||||
|
||||
_artists = results[0] as List<Artist>;
|
||||
_albums = results[1] as List<Album>;
|
||||
_tracks = results[2] as List<Track>;
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
|
||||
_error = errorInfo.userMessage;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
|
||||
_artists = await _api!.getArtists(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
albumArtistsOnly: showOnlyArtistsWithAlbums,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
||||
_error = errorInfo.userMessage;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
_albums = await _api!.getAlbums(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
artistId: artistId,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
||||
_error = errorInfo.userMessage;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DETAIL SCREEN DATA
|
||||
// ============================================================================
|
||||
|
||||
/// Get album tracks with caching
|
||||
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||
final cacheKey = '${provider}_$itemId';
|
||||
|
||||
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached album tracks for $cacheKey');
|
||||
return _cacheService.getCachedAlbumTracks(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
|
||||
final tracks = await _api!.getAlbumTracks(provider, itemId);
|
||||
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
|
||||
return tracks;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch album tracks: $e');
|
||||
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist tracks with caching
|
||||
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||
final cacheKey = '${provider}_$itemId';
|
||||
|
||||
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached playlist tracks for $cacheKey');
|
||||
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
|
||||
final tracks = await _api!.getPlaylistTracks(provider, itemId);
|
||||
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
|
||||
return tracks;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch playlist tracks: $e');
|
||||
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist albums with caching
|
||||
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
|
||||
final cacheKey = artistName.toLowerCase();
|
||||
|
||||
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached artist albums for "$artistName"');
|
||||
return _cacheService.getCachedArtistAlbums(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching albums for artist "$artistName"...');
|
||||
|
||||
final libraryAlbums = await _api!.getAlbums();
|
||||
final artistAlbums = libraryAlbums.where((album) {
|
||||
final albumArtists = album.artists;
|
||||
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
final searchResults = await _api!.search(artistName);
|
||||
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
|
||||
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
|
||||
final albumArtists = album.artists;
|
||||
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
final allAlbums = <Album>[];
|
||||
final seenNames = <String>{};
|
||||
|
||||
for (final album in [...artistAlbums, ...providerAlbums]) {
|
||||
final key = album.name.toLowerCase();
|
||||
if (!seenNames.contains(key)) {
|
||||
seenNames.add(key);
|
||||
allAlbums.add(album);
|
||||
}
|
||||
}
|
||||
|
||||
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
|
||||
return allAlbums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch artist albums: $e');
|
||||
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate album tracks cache
|
||||
void invalidateAlbumTracksCache(String albumId) {
|
||||
_cacheService.invalidateAlbumTracksCache(albumId);
|
||||
}
|
||||
|
||||
/// Invalidate playlist tracks cache
|
||||
void invalidatePlaylistTracksCache(String playlistId) {
|
||||
_cacheService.invalidatePlaylistTracksCache(playlistId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH
|
||||
// ============================================================================
|
||||
|
||||
/// Search with caching
|
||||
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
|
||||
final cacheKey = query.toLowerCase().trim();
|
||||
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
|
||||
|
||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached search results for "$query"');
|
||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Searching for "$query"...');
|
||||
final results = await _api!.search(query);
|
||||
|
||||
final cachedResults = <String, List<MediaItem>>{
|
||||
'artists': results['artists'] ?? [],
|
||||
'albums': results['albums'] ?? [],
|
||||
'tracks': results['tracks'] ?? [],
|
||||
};
|
||||
|
||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||
return cachedResults;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Search failed: $e');
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
||||
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
|
||||
|
||||
try {
|
||||
return await _api!.search(query, libraryOnly: libraryOnly);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Search', e);
|
||||
return {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
||||
if (_api == null) return [];
|
||||
|
||||
try {
|
||||
return await _api!.getAlbumTracks(provider, itemId);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Get album tracks', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
void clearState() {
|
||||
_artists = [];
|
||||
_albums = [];
|
||||
_tracks = [];
|
||||
_isLoading = false;
|
||||
_error = null;
|
||||
clearSearchState();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ class LocaleProvider extends ChangeNotifier {
|
||||
return 'English';
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
case 'es':
|
||||
return 'Español';
|
||||
case 'fr':
|
||||
return 'Français';
|
||||
case null:
|
||||
return 'System';
|
||||
default:
|
||||
|
||||
@@ -1,964 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
import '../services/device_id_service.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../main.dart' show audioHandler;
|
||||
|
||||
/// Provider for managing player state, selection, and controls
|
||||
class PlayerProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
late final LocalPlayerService _localPlayer;
|
||||
final CacheService _cacheService;
|
||||
|
||||
// Dependencies injected from parent provider
|
||||
MusicAssistantAPI? _api;
|
||||
AuthManager? _authManager;
|
||||
String? _serverUrl;
|
||||
|
||||
// Player selection
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
Track? _currentTrack;
|
||||
Timer? _playerStateTimer;
|
||||
Timer? _adjacentPlayerCacheTimer;
|
||||
|
||||
// Local Playback
|
||||
bool _isLocalPlayerPowered = true;
|
||||
StreamSubscription? _localPlayerEventSubscription;
|
||||
StreamSubscription? _playerUpdatedEventSubscription;
|
||||
Timer? _localPlayerStateReportTimer;
|
||||
|
||||
// Pending metadata from player_updated events
|
||||
TrackMetadata? _pendingTrackMetadata;
|
||||
TrackMetadata? _currentNotificationMetadata;
|
||||
|
||||
// Registration guard
|
||||
Completer<void>? _registrationInProgress;
|
||||
|
||||
// Getters
|
||||
Player? get selectedPlayer => _selectedPlayer;
|
||||
List<Player> get availablePlayers => _availablePlayers;
|
||||
Track? get currentTrack => _currentTrack;
|
||||
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
|
||||
|
||||
/// Get cached track for a player (used for smooth swipe transitions)
|
||||
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
|
||||
|
||||
PlayerProvider(this._cacheService, AuthManager authManager) {
|
||||
_authManager = authManager;
|
||||
_localPlayer = LocalPlayerService(authManager);
|
||||
}
|
||||
|
||||
/// Initialize with API reference after connection
|
||||
void initialize(MusicAssistantAPI api, String serverUrl) {
|
||||
_api = api;
|
||||
_serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
/// Set up event listeners after connection
|
||||
Future<void> setupEventListeners() async {
|
||||
if (_api == null) return;
|
||||
|
||||
// Listen to built-in player events
|
||||
_localPlayerEventSubscription?.cancel();
|
||||
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
|
||||
_handleLocalPlayerEvent,
|
||||
onError: (error) {
|
||||
_logger.log('Builtin player event stream error: $error');
|
||||
},
|
||||
);
|
||||
|
||||
// Listen to player_updated events
|
||||
_playerUpdatedEventSubscription?.cancel();
|
||||
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
|
||||
_handlePlayerUpdatedEvent,
|
||||
onError: (error) {
|
||||
_logger.log('Player updated event stream error: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Initialize local playback
|
||||
Future<void> initializeLocalPlayback() async {
|
||||
await _localPlayer.initialize();
|
||||
_isLocalPlayerPowered = true;
|
||||
|
||||
// Wire up skip button callbacks
|
||||
audioHandler.onSkipToNext = () {
|
||||
_logger.log('🎵 Notification: Skip to next pressed');
|
||||
nextTrackSelectedPlayer();
|
||||
};
|
||||
audioHandler.onSkipToPrevious = () {
|
||||
_logger.log('🎵 Notification: Skip to previous pressed');
|
||||
previousTrackSelectedPlayer();
|
||||
};
|
||||
|
||||
if (_api != null) {
|
||||
await registerLocalPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to adopt an existing ghost player
|
||||
Future<bool> tryAdoptGhostPlayer() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
final isFresh = await DeviceIdService.isFreshInstallation();
|
||||
if (!isFresh) {
|
||||
_logger.log('👻 Not a fresh install, skipping ghost adoption');
|
||||
return false;
|
||||
}
|
||||
|
||||
final ownerName = await SettingsService.getOwnerName();
|
||||
if (ownerName == null || ownerName.isEmpty) {
|
||||
_logger.log('👻 No owner name set, cannot adopt ghost player');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
|
||||
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
|
||||
if (adoptableId == null) {
|
||||
_logger.log('👻 No matching ghost player found - will generate new ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('👻 Found adoptable ghost: $adoptableId');
|
||||
await DeviceIdService.adoptPlayerId(adoptableId);
|
||||
|
||||
_logger.log('✅ Successfully adopted ghost player');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register local player with MA server
|
||||
Future<void> registerLocalPlayer() async {
|
||||
if (_api == null) return;
|
||||
|
||||
if (_registrationInProgress != null) {
|
||||
_logger.log('⏳ Registration already in progress, waiting...');
|
||||
return _registrationInProgress!.future;
|
||||
}
|
||||
|
||||
_registrationInProgress = Completer<void>();
|
||||
|
||||
try {
|
||||
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
_logger.log('🆔 Using player ID: $playerId');
|
||||
|
||||
await SettingsService.setBuiltinPlayerId(playerId);
|
||||
final name = await SettingsService.getLocalPlayerName();
|
||||
|
||||
final existingPlayers = await _api!.getPlayers();
|
||||
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||
|
||||
if (existingPlayer != null && existingPlayer.available) {
|
||||
_logger.log('✅ Player already registered and available: $playerId');
|
||||
_startReportingLocalPlayerState();
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.complete();
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
return;
|
||||
} else if (existingPlayer != null && !existingPlayer.available) {
|
||||
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
|
||||
} else {
|
||||
_logger.log('🆔 Player not found in MA, registering as new');
|
||||
}
|
||||
|
||||
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
|
||||
await _api!.registerBuiltinPlayer(playerId, name);
|
||||
|
||||
_logger.log('✅ Player registration complete');
|
||||
_startReportingLocalPlayerState();
|
||||
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.complete();
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
} catch (e) {
|
||||
_logger.log('❌ CRITICAL: Player registration failed: $e');
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.completeError(e);
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _startReportingLocalPlayerState() {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
|
||||
try {
|
||||
await _reportLocalPlayerState();
|
||||
} catch (e) {
|
||||
_logger.log('Error reporting local player state (will retry): $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reportLocalPlayerState() async {
|
||||
if (_api == null) return;
|
||||
|
||||
// Don't try to report state if not authenticated - avoids spamming errors
|
||||
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
|
||||
|
||||
final playerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (playerId == null) return;
|
||||
|
||||
final isPlaying = _localPlayer.isPlaying;
|
||||
final position = _localPlayer.position.inSeconds;
|
||||
final volume = (_localPlayer.volume * 100).round();
|
||||
final isPaused = !isPlaying && position > 0;
|
||||
|
||||
await _api!.updateBuiltinPlayerState(
|
||||
playerId,
|
||||
powered: _isLocalPlayerPowered,
|
||||
playing: isPlaying,
|
||||
paused: isPaused,
|
||||
position: position,
|
||||
volume: volume,
|
||||
muted: _localPlayer.volume == 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
|
||||
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
|
||||
|
||||
try {
|
||||
final eventPlayerId = event['player_id'] as String?;
|
||||
final myPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
|
||||
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
|
||||
return;
|
||||
}
|
||||
|
||||
final command = (event['type'] as String?) ?? (event['command'] as String?);
|
||||
|
||||
switch (command) {
|
||||
case 'play_media':
|
||||
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
|
||||
|
||||
if (urlPath != null && _serverUrl != null) {
|
||||
String fullUrl;
|
||||
if (urlPath.startsWith('http')) {
|
||||
fullUrl = urlPath;
|
||||
} else {
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
||||
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
|
||||
fullUrl = '$baseUrl$path';
|
||||
}
|
||||
|
||||
TrackMetadata metadata;
|
||||
if (_pendingTrackMetadata != null) {
|
||||
metadata = _pendingTrackMetadata!;
|
||||
} else {
|
||||
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
|
||||
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
|
||||
final albumName = event['album_name'] as String? ?? event['album'] as String?;
|
||||
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
|
||||
final durationSecs = event['duration'] as int?;
|
||||
|
||||
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
|
||||
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
|
||||
}
|
||||
|
||||
metadata = TrackMetadata(
|
||||
title: trackName,
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
artworkUrl: artworkUrl,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
);
|
||||
}
|
||||
|
||||
_localPlayer.setCurrentTrackMetadata(metadata);
|
||||
_currentNotificationMetadata = metadata;
|
||||
await _localPlayer.playUrl(fullUrl);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
await _localPlayer.stop();
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
await _localPlayer.pause();
|
||||
break;
|
||||
|
||||
case 'play':
|
||||
await _localPlayer.play();
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
final position = event['position'] as int?;
|
||||
if (position != null) {
|
||||
await _localPlayer.seek(Duration(seconds: position));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volume_set':
|
||||
final volume = event['volume_level'] as int?;
|
||||
if (volume != null) {
|
||||
await _localPlayer.setVolume(volume / 100.0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'power_on':
|
||||
case 'power_off':
|
||||
case 'power':
|
||||
bool? newPowerState;
|
||||
if (command == 'power_on') {
|
||||
newPowerState = true;
|
||||
} else if (command == 'power_off') {
|
||||
newPowerState = false;
|
||||
} else {
|
||||
newPowerState = event['powered'] as bool?;
|
||||
}
|
||||
|
||||
if (newPowerState != null) {
|
||||
_isLocalPlayerPowered = newPowerState;
|
||||
if (!_isLocalPlayerPowered) {
|
||||
await _localPlayer.stop();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
await _reportLocalPlayerState();
|
||||
} catch (e) {
|
||||
_logger.log('Error handling local player event: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
|
||||
try {
|
||||
final playerId = event['player_id'] as String?;
|
||||
if (playerId == null) return;
|
||||
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||
_updatePlayerState();
|
||||
}
|
||||
|
||||
final currentMedia = event['current_media'] as Map<String, dynamic>?;
|
||||
final playerName = event['name'] as String? ?? playerId;
|
||||
|
||||
if (currentMedia != null) {
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
if (mediaType != 'flow_stream') {
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
final albumName = currentMedia['album'] as String?;
|
||||
final imageUrl = currentMedia['image_url'] as String?;
|
||||
|
||||
Map<String, dynamic>? metadata;
|
||||
if (imageUrl != null && _serverUrl != null) {
|
||||
var finalImageUrl = imageUrl;
|
||||
try {
|
||||
final imgUri = Uri.parse(imageUrl);
|
||||
final queryString = imgUri.query;
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||
}
|
||||
finalImageUrl = '$baseUrl/imageproxy?$queryString';
|
||||
} catch (e) {
|
||||
// Use original URL
|
||||
}
|
||||
metadata = {
|
||||
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
|
||||
};
|
||||
}
|
||||
|
||||
final trackFromEvent = Track(
|
||||
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
||||
provider: 'library',
|
||||
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
||||
uri: currentMedia['uri'] as String?,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
|
||||
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||
_currentTrack = trackFromEvent;
|
||||
}
|
||||
|
||||
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
|
||||
|
||||
if (imageUrl != null) {
|
||||
final images = metadata?['images'] as List?;
|
||||
if (images != null && images.isNotEmpty) {
|
||||
final imagePath = images[0]['path'] as String?;
|
||||
if (imagePath != null) {
|
||||
_precacheImage(imagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle notification metadata for local player
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
|
||||
|
||||
if (currentMedia == null) return;
|
||||
|
||||
final title = currentMedia['title'] as String? ?? 'Unknown Track';
|
||||
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
|
||||
final album = currentMedia['album'] as String?;
|
||||
var imageUrl = currentMedia['image_url'] as String?;
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
|
||||
if (imageUrl != null && _serverUrl != null) {
|
||||
try {
|
||||
final imgUri = Uri.parse(imageUrl);
|
||||
final queryString = imgUri.query;
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||
}
|
||||
imageUrl = '$baseUrl/imageproxy?$queryString';
|
||||
} catch (e) {
|
||||
if (imageUrl != null && imageUrl.startsWith('http://')) {
|
||||
imageUrl = imageUrl.replaceFirst('http://', 'https://');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final newMetadata = TrackMetadata(
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
artworkUrl: imageUrl,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
);
|
||||
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
if (mediaType == 'flow_stream') return;
|
||||
|
||||
_pendingTrackMetadata = newMetadata;
|
||||
|
||||
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
|
||||
(_currentNotificationMetadata!.title != title ||
|
||||
_currentNotificationMetadata!.artist != artist);
|
||||
|
||||
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
|
||||
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
|
||||
_currentNotificationMetadata = newMetadata;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error handling player_updated event: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER SELECTION
|
||||
// ============================================================================
|
||||
|
||||
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final allPlayers = await _api!.getPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
||||
|
||||
int filteredCount = 0;
|
||||
|
||||
_availablePlayers = allPlayers.where((player) {
|
||||
final nameLower = player.name.toLowerCase();
|
||||
|
||||
if (nameLower.contains('music assistant mobile')) {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
|
||||
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
|
||||
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Also filter "This Device" named players without proper provider
|
||||
if (nameLower == 'this device') {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.playerId.startsWith('ensemble_')) {
|
||||
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!player.available) {
|
||||
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
||||
return true;
|
||||
}
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
_cacheService.updatePlayersLastFetched();
|
||||
|
||||
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
|
||||
|
||||
if (_availablePlayers.isNotEmpty) {
|
||||
Player? playerToSelect;
|
||||
|
||||
if (_selectedPlayer != null) {
|
||||
final stillAvailable = _availablePlayers.any(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
||||
);
|
||||
if (stillAvailable) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
|
||||
// Priority 1: Last selected player
|
||||
if (lastSelectedPlayerId != null) {
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
// Priority 2: Built-in (local) player
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.playerId == builtinPlayerId && p.available,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
// Priority 3: Currently playing player (exclude external sources - not playing MA content)
|
||||
if (playerToSelect == null) {
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.state == 'playing' && p.available && !p.isExternalSource,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.available,
|
||||
orElse: () => _availablePlayers.first,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
selectPlayer(playerToSelect);
|
||||
}
|
||||
|
||||
_preloadAdjacentPlayers(preloadAll: true);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Load and select players', e);
|
||||
}
|
||||
}
|
||||
|
||||
void selectPlayer(Player player, {bool skipNotify = false}) {
|
||||
_selectedPlayer = player;
|
||||
SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||
_startPlayerStatePolling();
|
||||
_preloadAdjacentPlayers();
|
||||
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
final players = _availablePlayers.where((p) => p.available).toList();
|
||||
if (players.isEmpty) return;
|
||||
|
||||
if (preloadAll) {
|
||||
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
|
||||
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||
_logger.log('🖼️ Preloading complete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedPlayer == null || players.length <= 1) return;
|
||||
|
||||
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
|
||||
if (currentIndex == -1) return;
|
||||
|
||||
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
||||
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
||||
|
||||
final playersToPreload = <Player>{};
|
||||
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
|
||||
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
|
||||
|
||||
for (final player in playersToPreload) {
|
||||
_preloadPlayerTrack(player);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preloadPlayerTrack(Player player) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
if (!player.available || !player.powered) {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||
return;
|
||||
}
|
||||
|
||||
final queue = await _api!.getQueue(player.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
final track = queue.currentItem!.track;
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||
final newHasImage = track.metadata?['images'] != null;
|
||||
|
||||
if (!existingHasImage || newHasImage) {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
||||
}
|
||||
|
||||
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
|
||||
if (artworkUrl512 != null) {
|
||||
await _precacheImage(artworkUrl512);
|
||||
}
|
||||
} else {
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
if (existingTrack?.metadata?['images'] == null) {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error preloading player track for ${player.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _precacheImage(String url) async {
|
||||
try {
|
||||
final imageProvider = NetworkImage(url);
|
||||
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||
final completer = Completer<void>();
|
||||
late ImageStreamListener listener;
|
||||
|
||||
listener = ImageStreamListener(
|
||||
(ImageInfo info, bool synchronousCall) {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
imageStream.removeListener(listener);
|
||||
},
|
||||
onError: (exception, stackTrace) {
|
||||
if (!completer.isCompleted) completer.completeError(exception);
|
||||
imageStream.removeListener(listener);
|
||||
},
|
||||
);
|
||||
|
||||
imageStream.addListener(listener);
|
||||
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||
imageStream.removeListener(listener);
|
||||
});
|
||||
} catch (e) {
|
||||
_logger.log('Image precache failed for $url: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> preloadAllPlayerTracks() async {
|
||||
if (_api == null) return;
|
||||
final players = _availablePlayers.where((p) => p.available).toList();
|
||||
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||
}
|
||||
|
||||
void _startPlayerStatePolling() {
|
||||
_playerStateTimer?.cancel();
|
||||
if (_selectedPlayer == null) return;
|
||||
|
||||
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
||||
try {
|
||||
await _updatePlayerState();
|
||||
} catch (e) {
|
||||
_logger.log('Error updating player state (will retry): $e');
|
||||
}
|
||||
});
|
||||
|
||||
_updatePlayerState();
|
||||
}
|
||||
|
||||
Future<void> _updatePlayerState() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
try {
|
||||
bool stateChanged = false;
|
||||
|
||||
final allPlayers = await _api!.getPlayers();
|
||||
final updatedPlayer = allPlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
orElse: () => _selectedPlayer!,
|
||||
);
|
||||
|
||||
_selectedPlayer = updatedPlayer;
|
||||
stateChanged = true;
|
||||
|
||||
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
|
||||
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
|
||||
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
|
||||
|
||||
if (!shouldShowTrack) {
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
if (stateChanged) notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
if (_currentTrack == null ||
|
||||
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||
_currentTrack!.name != queue.currentItem!.track.name) {
|
||||
_currentTrack = queue.currentItem!.track;
|
||||
stateChanged = true;
|
||||
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
_localPlayer.updateNotification(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: track.artistsString,
|
||||
album: track.album?.name,
|
||||
artworkUrl: artworkUrl,
|
||||
duration: track.duration,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged) notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error updating player state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER CONTROLS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> playPauseSelectedPlayer() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
if (_selectedPlayer!.isPlaying) {
|
||||
await _api!.pausePlayer(_selectedPlayer!.playerId);
|
||||
} else {
|
||||
await _api!.resumePlayer(_selectedPlayer!.playerId);
|
||||
}
|
||||
|
||||
await refreshPlayers();
|
||||
}
|
||||
|
||||
Future<void> nextTrackSelectedPlayer() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
await _api!.nextTrack(_selectedPlayer!.playerId);
|
||||
await Future.delayed(Timings.trackChangeDelay);
|
||||
await _updatePlayerState();
|
||||
}
|
||||
|
||||
Future<void> previousTrackSelectedPlayer() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
await _api!.previousTrack(_selectedPlayer!.playerId);
|
||||
await Future.delayed(Timings.trackChangeDelay);
|
||||
await _updatePlayerState();
|
||||
}
|
||||
|
||||
Future<void> refreshPlayers() async {
|
||||
final previousState = _selectedPlayer?.state;
|
||||
final previousVolume = _selectedPlayer?.volumeLevel;
|
||||
|
||||
await loadAndSelectPlayers(forceRefresh: true);
|
||||
|
||||
bool stateChanged = false;
|
||||
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
||||
try {
|
||||
final updatedPlayer = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
|
||||
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
_selectedPlayer = updatedPlayer;
|
||||
} catch (e) {
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged) notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> togglePower(String playerId) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final localPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
|
||||
|
||||
if (isLocalPlayer) {
|
||||
_isLocalPlayerPowered = !_isLocalPlayerPowered;
|
||||
if (!_isLocalPlayerPowered) {
|
||||
await _localPlayer.stop();
|
||||
}
|
||||
await _reportLocalPlayerState();
|
||||
await refreshPlayers();
|
||||
} else {
|
||||
final player = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == playerId,
|
||||
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
|
||||
? _selectedPlayer!
|
||||
: throw Exception("Player not found"),
|
||||
);
|
||||
|
||||
await _api!.setPower(playerId, !player.powered);
|
||||
await refreshPlayers();
|
||||
}
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Toggle power', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.setVolume(playerId, volumeLevel);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set volume', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setMute(String playerId, bool muted) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.setMute(playerId, muted);
|
||||
await refreshPlayers();
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set mute', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(String playerId, int position) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.seek(playerId, position);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Seek', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleShuffle(String queueId, bool shuffleEnabled) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.toggleShuffle(queueId, shuffleEnabled);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Toggle shuffle', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setRepeatMode(String queueId, String mode) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.setRepeatMode(queueId, mode);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set repeat mode', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
await setRepeatMode(queueId, nextMode);
|
||||
}
|
||||
|
||||
void clearState() {
|
||||
_playerStateTimer?.cancel();
|
||||
_playerStateTimer = null;
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerEventSubscription?.cancel();
|
||||
_playerUpdatedEventSubscription?.cancel();
|
||||
_availablePlayers = [];
|
||||
_selectedPlayer = null;
|
||||
_currentTrack = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
clearState();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
|
||||
/// Callback type for when sleep timer expires and should pause playback
|
||||
typedef OnSleepTimerExpired = void Function();
|
||||
|
||||
/// Provider for sleep timer functionality.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to reduce complexity and improve testability.
|
||||
/// This provider manages:
|
||||
/// - Timed sleep (pause after X minutes)
|
||||
/// - End of track sleep (pause when current track ends)
|
||||
class SleepTimerProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
|
||||
Timer? _sleepTimer;
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||
Timer? _sleepTimerDisplayTimer;
|
||||
|
||||
/// Callback to pause playback when timer expires
|
||||
OnSleepTimerExpired? onExpired;
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS
|
||||
// ============================================================================
|
||||
|
||||
/// Whether a sleep timer is currently active
|
||||
bool get isActive => _sleepTimerMinutes != null;
|
||||
|
||||
/// Current timer setting: null = off, -1 = end of track, positive = minutes
|
||||
int? get minutes => _sleepTimerMinutes;
|
||||
|
||||
/// Time remaining until sleep timer expires (null if end-of-track mode or inactive)
|
||||
Duration? get remaining {
|
||||
if (_sleepTimerEndTime == null) return null;
|
||||
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
|
||||
return remaining.isNegative ? Duration.zero : remaining;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIMER CONTROL
|
||||
// ============================================================================
|
||||
|
||||
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
|
||||
void setTimer(int? minutes) {
|
||||
// Cancel any existing timer
|
||||
_cancelInternal();
|
||||
|
||||
_sleepTimerMinutes = minutes;
|
||||
|
||||
if (minutes == null) {
|
||||
// Timer off
|
||||
_logger.log('😴 Sleep timer: OFF');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (minutes == -1) {
|
||||
// End of track - handled in player state updates
|
||||
_sleepTimerEndTime = null; // No fixed end time
|
||||
_logger.log('😴 Sleep timer: End of track');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set timer for specified minutes
|
||||
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
|
||||
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
|
||||
|
||||
// Create the actual timer
|
||||
_sleepTimer = Timer(Duration(minutes: minutes), _onTimerExpired);
|
||||
|
||||
// Start display update timer (every second for countdown)
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cancel sleep timer
|
||||
void cancel() {
|
||||
_cancelInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
_logger.log('😴 Sleep timer: Cancelled');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _cancelInternal() {
|
||||
_sleepTimer?.cancel();
|
||||
_sleepTimer = null;
|
||||
_sleepTimerDisplayTimer?.cancel();
|
||||
_sleepTimerDisplayTimer = null;
|
||||
}
|
||||
|
||||
void _onTimerExpired() {
|
||||
_logger.log('😴 Sleep timer expired - pausing playback');
|
||||
_cancelInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
|
||||
// Trigger the callback to pause playback
|
||||
onExpired?.call();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called when track ends - checks if "end of track" sleep timer is active
|
||||
void checkEndOfTrack() {
|
||||
if (_sleepTimerMinutes == -1) {
|
||||
_logger.log('😴 End of track sleep timer triggered');
|
||||
_sleepTimerMinutes = null;
|
||||
|
||||
// Trigger the callback to pause playback
|
||||
onExpired?.call();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelInternal();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
@@ -13,7 +14,9 @@ import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'audiobook_detail_screen.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
|
||||
class AudiobookAuthorScreen extends StatefulWidget {
|
||||
final String authorName;
|
||||
@@ -118,6 +121,149 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle favorite status for an audiobook
|
||||
Future<void> _toggleAudiobookFavorite(Audiobook book) async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !(book.favorite ?? false);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (newState) {
|
||||
// Add to favorites
|
||||
String actualProvider = book.provider;
|
||||
String actualItemId = book.itemId;
|
||||
|
||||
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
|
||||
final mapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => book.providerMappings!.firstWhere(
|
||||
(m) => m.available,
|
||||
orElse: () => book.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
success = await maProvider.addToFavorites(
|
||||
mediaType: 'audiobook',
|
||||
itemId: actualItemId,
|
||||
provider: actualProvider,
|
||||
);
|
||||
} else {
|
||||
// Remove from favorites
|
||||
int? libraryItemId;
|
||||
if (book.provider == 'library') {
|
||||
libraryItemId = int.tryParse(book.itemId);
|
||||
} else if (book.providerMappings != null) {
|
||||
final libraryMapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => book.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
setState(() {
|
||||
final index = _audiobooks.indexWhere((b) => b.itemId == book.itemId);
|
||||
if (index != -1) {
|
||||
_audiobooks[index] = Audiobook(
|
||||
itemId: book.itemId,
|
||||
provider: book.provider,
|
||||
name: book.name,
|
||||
sortName: book.sortName,
|
||||
uri: book.uri,
|
||||
providerMappings: book.providerMappings,
|
||||
favorite: newState,
|
||||
metadata: book.metadata,
|
||||
year: book.year,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling audiobook favorite: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle library status for an audiobook
|
||||
Future<void> _toggleAudiobookLibrary(Audiobook book) async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final newState = !book.inLibrary;
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (newState) {
|
||||
// Add to library
|
||||
String? actualProvider;
|
||||
String? actualItemId;
|
||||
|
||||
if (book.providerMappings != null && book.providerMappings!.isNotEmpty) {
|
||||
final mapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.available && m.providerInstance != 'library',
|
||||
orElse: () => book.providerMappings!.first,
|
||||
);
|
||||
if (mapping.providerInstance != 'library') {
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
}
|
||||
|
||||
if (actualProvider != null && actualItemId != null) {
|
||||
success = await maProvider.addToLibrary(
|
||||
mediaType: 'audiobook',
|
||||
itemId: actualItemId,
|
||||
provider: actualProvider,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
if (book.provider == 'library') {
|
||||
libraryItemId = int.tryParse(book.itemId);
|
||||
} else if (book.providerMappings != null) {
|
||||
final libraryMapping = book.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => book.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
} else {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Refresh would require reloading from parent - for now just show success
|
||||
_logger.log('Audiobook library status toggled');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling audiobook library: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||
@@ -150,10 +296,18 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 50% of screen width, clamped between 140-200 (matches artist detail)
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
|
||||
final expandedHeight = coverSize + 25;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: IconButton(
|
||||
@@ -172,37 +326,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
Hero(
|
||||
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _authorImageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _authorImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: 200,
|
||||
height: 200,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Icon always present underneath
|
||||
Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: coverSize * 0.5,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
// Image covers icon when loaded
|
||||
if (_authorImageUrl != null)
|
||||
SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
child: ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: _authorImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
memCacheWidth: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -212,83 +370,114 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.authorName,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
// Author info on the left
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.authorName,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.audiobookCount(_audiobooks.length),
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.audiobookCount(_audiobooks.length),
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
// Three-dot Menu Button on the right
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: Builder(
|
||||
builder: (buttonContext) => FilledButton.tonal(
|
||||
onPressed: () {
|
||||
final RenderBox box = buttonContext.findRenderObject() as RenderBox;
|
||||
final Offset position = box.localToGlobal(Offset(box.size.width / 2, box.size.height));
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: position,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: _audiobooks.isNotEmpty ? _audiobooks.first : null,
|
||||
isFavorite: false,
|
||||
isInLibrary: false,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
showTopRow: false,
|
||||
sortOrder: _sortOrder,
|
||||
onToggleSort: _toggleSortOrder,
|
||||
viewMode: _viewMode,
|
||||
onCycleView: _cycleViewMode,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.more_vert,
|
||||
size: 25,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Audiobooks Section Header with controls
|
||||
// Audiobooks Section Header
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.audiobooks,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Sort toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
|
||||
onPressed: _toggleSortOrder,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// View mode toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_viewMode == 'list'
|
||||
? Icons.view_list
|
||||
: _viewMode == 'grid3'
|
||||
? Icons.grid_view
|
||||
: Icons.grid_on,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _viewMode == 'grid2'
|
||||
? S.of(context)!.threeColumnGrid
|
||||
: _viewMode == 'grid3'
|
||||
? S.of(context)!.listView
|
||||
: S.of(context)!.twoColumnGrid,
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 8.0),
|
||||
child: Text(
|
||||
S.of(context)!.audiobooks,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildAudiobookSliver(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: BottomSpacing.withMiniPlayer,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface.withOpacity(0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -334,10 +523,31 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final heroSuffix = 'author${_heroTagSuffix}';
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Get adaptive scheme for context menu
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final adaptiveScheme = themeProvider.adaptiveTheme
|
||||
? (isDark ? _darkColorScheme : _lightColorScheme)
|
||||
: null;
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: book,
|
||||
isFavorite: book.favorite ?? false,
|
||||
isInLibrary: book.inLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
onToggleFavorite: () => _toggleAudiobookFavorite(book),
|
||||
onToggleLibrary: () => _toggleAudiobookLibrary(book),
|
||||
);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
@@ -395,6 +605,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -423,7 +638,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -435,9 +651,30 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final heroSuffix = 'author${_heroTagSuffix}';
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
leading: Hero(
|
||||
// Get adaptive scheme for context menu
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final adaptiveScheme = themeProvider.adaptiveTheme
|
||||
? (isDark ? _darkColorScheme : _lightColorScheme)
|
||||
: null;
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) {
|
||||
MediaContextMenu.show(
|
||||
context: context,
|
||||
position: details.globalPosition,
|
||||
mediaType: ContextMenuMediaType.audiobook,
|
||||
item: book,
|
||||
isFavorite: book.favorite ?? false,
|
||||
isInLibrary: book.inLibrary,
|
||||
adaptiveColorScheme: adaptiveScheme,
|
||||
onToggleFavorite: () => _toggleAudiobookFavorite(book),
|
||||
onToggleLibrary: () => _toggleAudiobookLibrary(book),
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
leading: Hero(
|
||||
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -518,7 +755,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||