Compare commits

..
9 Commits
Author SHA1 Message Date
Chris e5cefca71f WIP on feat/mixes-local: a4554ef reverted build file 2026-02-05 07:31:10 +00:00
Chris f658393d30 index on feat/mixes-local: a4554ef reverted build file 2026-02-05 07:31:10 +00:00
relic664 a4554ef7d7 reverted build file 2026-02-03 15:43:10 -05:00
relic664 a078eb55e8 fixed library screen 2026-02-02 22:24:30 -05:00
relic664 dfe3f26515 removed redundant shelf, header 2026-02-02 22:08:36 -05:00
relic664 d3d87dceda shelf refinement 2026-02-02 21:55:32 -05:00
relic664 73bb12fc03 overlay again 2026-02-02 21:24:16 -05:00
relic664 e975e24939 adjusted overlay 2026-02-02 20:58:25 -05:00
relic664 707768e524 added support for provider mixes 2026-02-02 14:43:16 -05:00
69 changed files with 2215 additions and 3817 deletions
-3
View File
@@ -1,3 +0,0 @@
# These are the funding models supported by Ensemble
github: CollotsSpot
custom: https://ko-fi.com/collotsspot
+391
View File
@@ -0,0 +1,391 @@
# 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**
+419
View File
@@ -0,0 +1,419 @@
# 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.*
+119
View File
@@ -0,0 +1,119 @@
# 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
+257
View File
@@ -0,0 +1,257 @@
# Ensemble Codebase Audit Report
**Date**: 2026-01-14
**Scope**: Full codebase audit (104 Dart files, 18 screens)
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
---
## Executive Summary
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
| Severity | Count | Key Themes |
|----------|-------|------------|
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
| **Low** | 15+ | Minor polish items |
**Top 3 Priorities:**
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
---
## Critical Issues
### 1. God Object: MusicAssistantProvider (5,443 lines)
**File**: `lib/providers/music_assistant_provider.dart`
- 54 `notifyListeners()` calls
- 30+ state variables
- 100+ public methods
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
### 2. God Object: NewLibraryScreen (4,209 lines)
**File**: `lib/screens/new_library_screen.dart`
- 10 scroll controllers
- 15+ view mode states
- 15+ sort order states
- Handles: Music, Books, Podcasts, Radio tabs
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
### 3. Sequential Blocking Startup (500-1500ms wasted)
**File**: `lib/main.dart:33-66`
```dart
await DatabaseService.instance.initialize();
await ProfileService.instance.migrateFromOwnerName();
await SettingsService.migrateToSecureStorage();
await SyncService.instance.loadFromCache();
audioHandler = await AudioService.init(...);
```
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
### 4. Design Tokens Exist But Unused
**File**: `lib/theme/design_tokens.dart`
- `Spacing.*` used in only ~6 of 34+ widget files
- `Radii.*` used in only ~5 files
- `IconSizes.*` used in only ~4 files
- **Fix**: Systematic replacement of hardcoded values
### 5. Touch Targets Too Small (32x32)
**File**: `lib/screens/new_library_screen.dart:1805-1867`
- Sort, favorites, view mode buttons are 32x32
- Material Design minimum: 48x48
- **Fix**: Increase to 48x48 or add padding for hit area
### 6. No Batch Database Inserts
**File**: `lib/services/sync_service.dart:212-273`
- 1000 albums = 1000 separate DB transactions
- **Fix**: Wrap in single transaction or use Drift batch insert
---
## High Severity Issues
### Performance
| Issue | File | Impact |
|-------|------|--------|
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
### Architecture
| Issue | File | Impact |
|-------|------|--------|
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
### UI/UX
| Issue | File | Impact |
|-------|------|--------|
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
### Error Handling
| Issue | File | Impact |
|-------|------|--------|
| No Flutter error boundaries | main.dart | Red error screen on build failure |
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
---
## Medium Severity Issues
### Performance
- PaletteGenerator on main isolate (10-30ms blocking)
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
- AppStartup polling loop with fixed 250ms delay
- SystemThemeHelper FutureBuilder recreated on every build
### UI Consistency
- Border radius varies: 8, 12, 20, 22, 25 for same element types
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
- Hardcoded colors for status (Colors.red, Colors.green)
- Inconsistent icon sizes across screens
### State Management
- Over-notification (multiple notifyListeners in single methods)
- Wide Consumer scope (watch instead of select)
- Missing dispose() in some providers
- Inconsistent async state handling (loading/error)
### Navigation
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
- `popToHome()` doesn't clear adaptive colors
- Infinite navigation loop possible (Album → Artist → Album)
- Orphaned Hero animations in podcast_card, radio_station_card
### Data/Caching
- Unbounded `_podcastCoverCache`
- SyncService listener not removed on error
- Redundant API calls between providers
- Fire-and-forget persistence race condition
### Error Handling
- No zone error handler for async exceptions
- `retryCritical()` retries all errors including non-recoverable
- No user notification of offline queue status
- DisconnectedState not used consistently
---
## Quick Wins (High Impact, Low Effort)
| # | Task | File(s) | Effort |
|---|------|---------|--------|
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
| 5 | Add Flutter error boundary | main.dart | 30 min |
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
---
## Refactoring Roadmap
### Phase 1: Quick Wins (1-2 days)
- Fix touch targets
- Fix bottom spacing
- Add navigation debounce
- Add error boundaries
- Cache SharedPreferences singleton
### Phase 2: Performance (3-5 days)
- Parallelize startup operations
- Replace `context.watch()` with `context.select()`
- Batch database inserts
- Add SecureStorage migration skip flag
- Fix over-notification in provider
### Phase 3: Design System (1 week)
- Systematic design token adoption
- Standardize button sizes (44 vs 50)
- Standardize border radius values
- Create StatusColors semantic tokens
- Replace all magic numbers
### Phase 4: Architecture (2-3 weeks)
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
---
## Positive Findings
The codebase already demonstrates many good practices:
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
- **Theme system**: Material You support, adaptive colors from album art
- **Offline support**: Cache-first loading, graceful degradation
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
---
## Files by Priority
| Priority | File | Lines | Key Issues |
|----------|------|-------|------------|
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
| 3 | main.dart | 549 | Startup blocking, error boundaries |
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
| 8 | All detail screens | Various | Hardcoded bottom spacing |
| 9 | All card widgets | Various | Navigation debounce, design tokens |
---
## Audit Methodology
This audit was conducted using 9 parallel sub-agents analyzing:
**UI Continuity (3 agents)**
1. Navigation & Flow - back button, Hero animations, tab preservation
2. Visual Consistency - design tokens, spacing, typography
3. Component Placement - touch targets, bottom spacing, modals
**Performance (3 agents)**
4. Rendering Performance - ListView.builder, const, rebuilds
5. Data & Caching - streams, invalidation, memory bounds
6. Startup & Load Time - blocking operations, initialization order
**Logic/Best Practices (3 agents)**
7. State Management - Provider patterns, notification frequency
8. Error Handling - try-catch, boundaries, resilience
9. Code Quality - file size, duplication, SOLID principles
---
## Conclusion
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
1. **Splitting the two 4,000+ line files** into focused components
2. **Adopting design tokens systematically** for visual consistency
3. **Optimizing startup sequence** for faster app launch
4. **Using granular state management** (select vs watch) for better performance
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
+57
View File
@@ -0,0 +1,57 @@
# 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`
-11
View File
@@ -1,11 +1,6 @@
<div align="center">
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
[![GitHub release](https://img.shields.io/github/v/release/CollotsSpot/Ensemble?style=for-the-badge&logo=github)](https://github.com/CollotsSpot/Ensemble/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub-EA4AAA?style=for-the-badge&logo=GitHub%20Sponsors&logoColor=white)](https://github.com/sponsors/CollotsSpot)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-FF5E5B?style=for-the-badge&logo=Ko-fi&logoColor=white)](https://ko-fi.com/collotsspot)
---
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
@@ -151,12 +146,6 @@ For access outside your home network, you'll need to expose Music Assistant thro
- 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
+1 -1
View File
@@ -30,7 +30,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace "com.collotsspot.ensemble"
compileSdk 36
compileSdk 35
ndkVersion flutter.ndkVersion
compileOptions {
-6
View File
@@ -1,11 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
@@ -1,11 +1,5 @@
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
@@ -20,22 +14,10 @@ class MainActivity: AudioServiceActivity() {
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}")
@@ -50,24 +32,6 @@ class MainActivity: AudioServiceActivity() {
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()
@@ -76,82 +40,6 @@ class MainActivity: AudioServiceActivity() {
}
}
/// 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 {
@@ -196,34 +84,4 @@ class MainActivity: AudioServiceActivity() {
}
}
}
// 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()
}
}
+7 -1
View File
@@ -2,11 +2,14 @@ buildscript {
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.9.1'
classpath 'com.android.tools.build:gradle:8.7.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
@@ -14,6 +17,9 @@ 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()
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
+1 -1
View File
@@ -22,7 +22,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.9.1" apply false
id "com.android.application" version "8.7.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
-242
View File
@@ -1,242 +0,0 @@
# 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
+3 -7
View File
@@ -2996,8 +2996,7 @@ final class $$ProfilesTableReferences
$$RecentlyPlayedTableProcessedTableManager get recentlyPlayedRefs {
final manager = $$RecentlyPlayedTableTableManager($_db, $_db.recentlyPlayed)
.filter((f) => f.profileUsername.username
.sqlEquals($_itemColumn<String>('username')!));
.filter((f) => f.profileUsername.username($_item.username));
final cache = $_typedResult.readTableOrNull(_recentlyPlayedRefsTable($_db));
return ProcessedTableManager(
@@ -3190,8 +3189,7 @@ class $$ProfilesTableTableManager extends RootTableManager<
getPrefetchedDataCallback: (items) async {
return [
if (recentlyPlayedRefs)
await $_getPrefetchedData<Profile, $ProfilesTable,
RecentlyPlayedData>(
await $_getPrefetchedData(
currentTable: table,
referencedTable: $$ProfilesTableReferences
._recentlyPlayedRefsTable(db),
@@ -3256,10 +3254,8 @@ final class $$RecentlyPlayedTableReferences extends BaseReferences<
db.recentlyPlayed.profileUsername, db.profiles.username));
$$ProfilesTableProcessedTableManager get profileUsername {
final $_column = $_itemColumn<String>('profile_username')!;
final manager = $$ProfilesTableTableManager($_db, $_db.profiles)
.filter((f) => f.username.sqlEquals($_column));
.filter((f) => f.username($_item.profileUsername));
final item = $_typedResult.readTableOrNull(_profileUsernameTable($_db));
if (item == null) return manager;
return ProcessedTableManager(
+4 -11
View File
@@ -52,6 +52,7 @@
"recentlyPlayed": "Zuletzt gespielt",
"discoverArtists": "Kunstler entdecken",
"discoverAlbums": "Alben entdecken",
"playlistMixes": "Playlists entdecken",
"continueListening": "Weiterhoren",
"discoverAudiobooks": "Horbücher entdecken",
"discoverSeries": "Serien entdecken",
@@ -171,6 +172,7 @@
"noArtistsFound": "Keine Künstler gefunden",
"noTracksFound": "Keine Songs gefunden",
"noPlaylistsFound": "Keine Wiedergabelisten gefunden",
"noPlaylistMixesFound": "Keine Playlist-Mixes gefunden",
"queueIsEmpty": "Warteschlange ist leer",
"noResultsFound": "Keine Ergebnisse gefunden",
"refresh": "Aktualisieren",
@@ -216,6 +218,7 @@
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
"showPlaylistMixes": "Playlist-Mixes von externen Anbietern anzeigen",
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
@@ -295,15 +298,5 @@
"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",
"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"
"swipeToAdjustVolume": "Wischen für Lautstärke"
}
+9 -83
View File
@@ -221,34 +221,9 @@
"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"
"playlistMixes": "Discover Playlists",
"@playlistMixes": {
"description": "Home screen section title"
},
"continueListening": "Continue Listening",
@@ -955,6 +930,11 @@
"description": "Empty playlists"
},
"noPlaylistMixesFound": "No playlist mixes found",
"@noPlaylistMixesFound": {
"description": "Empty playlist mixes"
},
"queueIsEmpty": "Queue is empty",
"@queueIsEmpty": {
"description": "Empty queue"
@@ -1115,6 +1095,7 @@
"showRecentlyPlayedAlbums": "Show recently played albums",
"showRandomArtists": "Show random artists to discover",
"showRandomAlbums": "Show random albums to discover",
"showPlaylistMixes": "Show playlist mixes from external providers",
"showAudiobooksInProgress": "Show audiobooks in progress",
"showRandomAudiobooks": "Show random audiobooks to discover",
"showRandomSeries": "Show random audiobook series to discover",
@@ -1713,60 +1694,5 @@
"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"
}
}
+4 -11
View File
@@ -51,6 +51,7 @@
"recentlyPlayed": "Reproducido recientemente",
"discoverArtists": "Descubrir artistas",
"discoverAlbums": "Descubrir álbumes",
"playlistMixes": "Descubrir listas de reproducción",
"continueListening": "Continuar escuchando",
"discoverAudiobooks": "Descubrir audiolibros",
"discoverSeries": "Descubrir series",
@@ -320,6 +321,7 @@
"noArtistsFound": "No se encontraron artistas",
"noTracksFound": "No se encontraron canciones",
"noPlaylistsFound": "No se encontraron listas de reproducción",
"noPlaylistMixesFound": "No se encontraron mezclas de playlists",
"queueIsEmpty": "La cola está vacía",
"noResultsFound": "No se encontraron resultados",
"refresh": "Actualizar",
@@ -372,6 +374,7 @@
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
"showPlaylistMixes": "Mostrar mezclas de playlists de proveedores externos",
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
@@ -491,15 +494,5 @@
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
"holdToSync": "Mantén presionado para sync",
"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"
"swipeToAdjustVolume": "Desliza para ajustar volumen"
}
+12 -11
View File
@@ -221,6 +221,11 @@
"description": "Home screen section title"
},
"playlistMixes": "Découvrir des playlists",
"@playlistMixes": {
"description": "Home screen section title"
},
"continueListening": "Continuer l'écoute",
"@continueListening": {
"description": "Home screen section title"
@@ -915,6 +920,11 @@
"description": "Empty playlists"
},
"noPlaylistMixesFound": "Aucun mix de playlists trouve",
"@noPlaylistMixesFound": {
"description": "Empty playlist mixes"
},
"queueIsEmpty": "La file d'attente est vide",
"@queueIsEmpty": {
"description": "Empty queue"
@@ -1071,6 +1081,7 @@
"showRecentlyPlayedAlbums": "Afficher les albums écoutes recemment",
"showRandomArtists": "Afficher des artistes aléatoires a découvrir",
"showRandomAlbums": "Afficher des albums aléatoires a découvrir",
"showPlaylistMixes": "Afficher les mixes de playlists provenant de fournisseurs externes",
"showAudiobooksInProgress": "Afficher les livres audio en cours",
"showRandomAudiobooks": "Afficher des livres audio aléatoires a découvrir",
"showRandomSeries": "Afficher des séries de livres audio aléatoires a découvrir",
@@ -1396,15 +1407,5 @@
"type": "String"
}
}
},
"aboutAndSupport": "À propos et Support",
"supportDevelopment": "Soutenir le Développement",
"ensembleIsUnofficial": "Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.",
"sponsorOnGitHub": "Sponsoriser sur GitHub",
"buyMeAKoFi": "Offrez-moi un Ko-fi",
"aboutEnsemble": "À propos d'Ensemble",
"version": "Version",
"visitGitHubRepo": "Visiter le Dépôt GitHub",
"reportABug": "Signaler un Bug"
}
}
+15 -99
View File
@@ -359,41 +359,11 @@ abstract class S {
/// **'Discover Albums'**
String get discoverAlbums;
/// Provider discovery mixes (e.g., Tidal playlists)
/// Home screen section title
///
/// In en, this message translates to:
/// **'Discovery Mixes'**
String get discoveryMixes;
/// Description for discovery mixes setting
///
/// In en, this message translates to:
/// **'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen'**
String get discoveryMixesDescription;
/// Label for discovery rows section
///
/// In en, this message translates to:
/// **'Discovery rows from Music Assistant'**
String get discoveryRows;
/// Button to refresh discovery rows
///
/// In en, this message translates to:
/// **'Refresh available rows'**
String get refreshDiscoveryRows;
/// Empty state when no discovery rows available
///
/// In en, this message translates to:
/// **'No discovery rows available'**
String get noDiscoveryRows;
/// Badge label for discovery rows
///
/// In en, this message translates to:
/// **'Music Assistant'**
String get discoveryRowBadge;
/// **'Discover Playlists'**
String get playlistMixes;
/// Home screen section title
///
@@ -1085,6 +1055,12 @@ abstract class S {
/// **'No playlists found'**
String get noPlaylistsFound;
/// Empty playlist mixes
///
/// In en, this message translates to:
/// **'No playlist mixes found'**
String get noPlaylistMixesFound;
/// Empty queue
///
/// In en, this message translates to:
@@ -1337,6 +1313,12 @@ abstract class S {
/// **'Show random albums to discover'**
String get showRandomAlbums;
/// No description provided for @showPlaylistMixes.
///
/// In en, this message translates to:
/// **'Show playlist mixes from external providers'**
String get showPlaylistMixes;
/// No description provided for @showAudiobooksInProgress.
///
/// In en, this message translates to:
@@ -2218,72 +2200,6 @@ abstract class S {
/// In en, this message translates to:
/// **'Select player to add to queue'**
String get selectPlayerToAddToQueue;
/// About and support section title
///
/// In en, this message translates to:
/// **'About & Support'**
String get aboutAndSupport;
/// Support development section
///
/// In en, this message translates to:
/// **'Support Development'**
String get supportDevelopment;
/// Description about Ensemble being unofficial
///
/// In en, this message translates to:
/// **'Ensemble is an unofficial, community-built client. Consider supporting its development.'**
String get ensembleIsUnofficial;
/// GitHub Sponsors button label
///
/// In en, this message translates to:
/// **'Sponsor on GitHub'**
String get sponsorOnGitHub;
/// Ko-fi donation button label
///
/// In en, this message translates to:
/// **'Buy me a Ko-fi'**
String get buyMeAKoFi;
/// About Ensemble section
///
/// In en, this message translates to:
/// **'About Ensemble'**
String get aboutEnsemble;
/// Version label
///
/// In en, this message translates to:
/// **'Version'**
String get version;
/// Link to GitHub repo
///
/// In en, this message translates to:
/// **'Visit GitHub Repository'**
String get visitGitHubRepo;
/// Link to report issues
///
/// In en, this message translates to:
/// **'Report a Bug'**
String get reportABug;
/// Empty state when no home screen rows are enabled
///
/// In en, this message translates to:
/// **'No rows enabled'**
String get noRowsEnabled;
/// Hint when home screen is empty
///
/// In en, this message translates to:
/// **'Pull down to refresh or enable rows in settings'**
String get pullToRefreshHint;
}
class _SDelegate extends LocalizationsDelegate<S> {
+8 -52
View File
@@ -152,23 +152,7 @@ class SDe extends S {
String get discoverAlbums => 'Alben entdecken';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
String get playlistMixes => 'Playlists entdecken';
@override
String get continueListening => 'Weiterhoren';
@@ -570,6 +554,9 @@ class SDe extends S {
@override
String get noPlaylistsFound => 'Keine Wiedergabelisten gefunden';
@override
String get noPlaylistMixesFound => 'Keine Playlist-Mixes gefunden';
@override
String get queueIsEmpty => 'Warteschlange ist leer';
@@ -701,6 +688,10 @@ class SDe extends S {
@override
String get showRandomAlbums => 'Zufällige Alben zum Entdecken anzeigen';
@override
String get showPlaylistMixes =>
'Playlist-Mixes von externen Anbietern anzeigen';
@override
String get showAudiobooksInProgress => 'Hörbücher in Bearbeitung anzeigen';
@@ -1205,39 +1196,4 @@ class SDe extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'Über & Unterstützung';
@override
String get supportDevelopment => 'Entwicklung unterstützen';
@override
String get ensembleIsUnofficial =>
'Ensemble ist ein inoffizieller, von der Community erstellter Client. Erwäge, die Entwicklung zu unterstützen.';
@override
String get sponsorOnGitHub => 'Auf GitHub sponsoren';
@override
String get buyMeAKoFi => 'Kauf mir einen Ko-fi';
@override
String get aboutEnsemble => 'Über Ensemble';
@override
String get version => 'Version';
@override
String get visitGitHubRepo => 'GitHub-Repository besuchen';
@override
String get reportABug => 'Einen Fehler melden';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+7 -52
View File
@@ -149,23 +149,7 @@ class SEn extends S {
String get discoverAlbums => 'Discover Albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
String get playlistMixes => 'Discover Playlists';
@override
String get continueListening => 'Continue Listening';
@@ -565,6 +549,9 @@ class SEn extends S {
@override
String get noPlaylistsFound => 'No playlists found';
@override
String get noPlaylistMixesFound => 'No playlist mixes found';
@override
String get queueIsEmpty => 'Queue is empty';
@@ -695,6 +682,9 @@ class SEn extends S {
@override
String get showRandomAlbums => 'Show random albums to discover';
@override
String get showPlaylistMixes => 'Show playlist mixes from external providers';
@override
String get showAudiobooksInProgress => 'Show audiobooks in progress';
@@ -1196,39 +1186,4 @@ class SEn extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'About & Support';
@override
String get supportDevelopment => 'Support Development';
@override
String get ensembleIsUnofficial =>
'Ensemble is an unofficial, community-built client. Consider supporting its development.';
@override
String get sponsorOnGitHub => 'Sponsor on GitHub';
@override
String get buyMeAKoFi => 'Buy me a Ko-fi';
@override
String get aboutEnsemble => 'About Ensemble';
@override
String get version => 'Version';
@override
String get visitGitHubRepo => 'Visit GitHub Repository';
@override
String get reportABug => 'Report a Bug';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+8 -52
View File
@@ -153,23 +153,7 @@ class SEs extends S {
String get discoverAlbums => 'Descubrir álbumes';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
String get playlistMixes => 'Descubrir listas de reproducción';
@override
String get continueListening => 'Continuar escuchando';
@@ -573,6 +557,9 @@ class SEs extends S {
@override
String get noPlaylistsFound => 'No se encontraron listas de reproducción';
@override
String get noPlaylistMixesFound => 'No se encontraron mezclas de playlists';
@override
String get queueIsEmpty => 'La cola está vacía';
@@ -706,6 +693,10 @@ class SEs extends S {
@override
String get showRandomAlbums => 'Mostrar álbumes aleatorios para descubrir';
@override
String get showPlaylistMixes =>
'Mostrar mezclas de playlists de proveedores externos';
@override
String get showAudiobooksInProgress => 'Mostrar audiolibros en progreso';
@@ -1213,39 +1204,4 @@ class SEs extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'Acerca de y Soporte';
@override
String get supportDevelopment => 'Apoyar el Desarrollo';
@override
String get ensembleIsUnofficial =>
'Ensemble es un cliente no oficial creado por la comunidad. Considera apoyar su desarrollo.';
@override
String get sponsorOnGitHub => 'Patrocina en GitHub';
@override
String get buyMeAKoFi => 'Cómprame un Ko-fi';
@override
String get aboutEnsemble => 'Acerca de Ensemble';
@override
String get version => 'Versión';
@override
String get visitGitHubRepo => 'Visitar Repositorio GitHub';
@override
String get reportABug => 'Reportar un Error';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+8 -52
View File
@@ -154,23 +154,7 @@ class SFr extends S {
String get discoverAlbums => 'Découvrir des albums';
@override
String get discoveryMixes => 'Discovery Mixes';
@override
String get discoveryMixesDescription =>
'Show provider recommendation mixes (e.g., Tidal Daily Mixes, Discover Weekly) on home screen';
@override
String get discoveryRows => 'Discovery rows from Music Assistant';
@override
String get refreshDiscoveryRows => 'Refresh available rows';
@override
String get noDiscoveryRows => 'No discovery rows available';
@override
String get discoveryRowBadge => 'Music Assistant';
String get playlistMixes => 'Découvrir des playlists';
@override
String get continueListening => 'Continuer l\'écoute';
@@ -574,6 +558,9 @@ class SFr extends S {
@override
String get noPlaylistsFound => 'Aucune playlist trouvee';
@override
String get noPlaylistMixesFound => 'Aucun mix de playlists trouve';
@override
String get queueIsEmpty => 'La file d\'attente est vide';
@@ -708,6 +695,10 @@ class SFr extends S {
@override
String get showRandomAlbums => 'Afficher des albums aléatoires a découvrir';
@override
String get showPlaylistMixes =>
'Afficher les mixes de playlists provenant de fournisseurs externes';
@override
String get showAudiobooksInProgress => 'Afficher les livres audio en cours';
@@ -1222,39 +1213,4 @@ class SFr extends S {
@override
String get selectPlayerToAddToQueue => 'Select player to add to queue';
@override
String get aboutAndSupport => 'À propos et Support';
@override
String get supportDevelopment => 'Soutenir le Développement';
@override
String get ensembleIsUnofficial =>
'Ensemble est un client non officiel créé par la communauté. Envisagez de soutenir son développement.';
@override
String get sponsorOnGitHub => 'Sponsoriser sur GitHub';
@override
String get buyMeAKoFi => 'Offrez-moi un Ko-fi';
@override
String get aboutEnsemble => 'À propos d\'Ensemble';
@override
String get version => 'Version';
@override
String get visitGitHubRepo => 'Visiter le Dépôt GitHub';
@override
String get reportABug => 'Signaler un Bug';
@override
String get noRowsEnabled => 'No rows enabled';
@override
String get pullToRefreshHint =>
'Pull down to refresh or enable rows in settings';
}
+78 -133
View File
@@ -31,85 +31,82 @@ late MassivAudioHandler audioHandler;
// Global debug logger instance
final _logger = DebugLogger();
void main() {
// All initialization must run inside the same zone as runApp() to avoid
// "Zone mismatch" errors from Flutter's binding system
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance
await SettingsService.initialize();
_logger.log('⚡ SharedPreferences cached');
// Initialize local database
await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized');
// Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName();
// Migrate credentials to secure storage (one-time for existing users)
await SettingsService.migrateToSecureStorage();
_logger.log('🔐 Secure storage migration complete');
// Load library from cache for instant startup
await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded');
// 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
};
// Wrap app in zone to catch async errors
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SharedPreferences cache early for performance
await SettingsService.initialize();
_logger.log('⚡ SharedPreferences cached');
// Initialize local database
await DatabaseService.instance.initialize();
_logger.log('💾 Database initialized');
// Migrate existing ownerName to profile (one-time for existing users)
await ProfileService.instance.migrateFromOwnerName();
// Migrate credentials to secure storage (one-time for existing users)
await SettingsService.migrateToSecureStorage();
_logger.log('🔐 Secure storage migration complete');
// Load library from cache for instant startup
await SyncService.instance.loadFromCache();
_logger.log('📦 Library cache loaded');
// 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());
},
() => runApp(const MusicAssistantApp()),
(error, stackTrace) {
_logger.error('Uncaught async error: $error', context: 'ZoneError');
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
@@ -131,7 +128,6 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub;
StreamSubscription? _absoluteVolumeSub;
String? _lastSelectedPlayerId;
String? _builtinPlayerId;
@@ -162,12 +158,6 @@ 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);
}
@@ -183,41 +173,18 @@ 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 &&
player?.playerId == _builtinPlayerId;
_musicProvider.selectedPlayer?.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;
// 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) {
final newVolume = (player.volume + delta).clamp(0, 100);
if (newVolume != player.volume) {
try {
await _musicProvider.setVolume(player.playerId, newVolume);
} catch (e) {
@@ -226,29 +193,11 @@ 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();
@@ -535,9 +484,6 @@ 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(() {
@@ -546,7 +492,6 @@ 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;
});
+32
View File
@@ -10,6 +10,29 @@ enum MediaType {
podcastEpisode,
}
/// Parse a media item JSON map into the appropriate model type.
MediaItem parseMediaItemJson(Map<String, dynamic> json) {
final mediaTypeStr = (json['media_type'] as String?)?.toLowerCase();
switch (mediaTypeStr) {
case 'artist':
return Artist.fromJson(json);
case 'album':
return Album.fromJson(json);
case 'track':
return Track.fromJson(json);
case 'playlist':
return Playlist.fromJson(json);
case 'audiobook':
return Audiobook.fromJson(json);
case 'radio':
return MediaItem.fromJson(json);
case 'podcast':
case 'podcast_episode':
default:
return MediaItem.fromJson(json);
}
}
class ProviderMapping {
final String itemId;
final String providerDomain;
@@ -356,6 +379,15 @@ class Playlist extends MediaItem {
if (provider == 'library') return true;
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
if (owner != null) json['owner'] = owner;
if (isEditable != null) json['is_editable'] = isEditable;
if (trackCount != null) json['track_count'] = trackCount;
return json;
}
}
class Chapter {
+1 -27
View File
@@ -15,15 +15,11 @@ class RecommendationFolder {
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)
.map(parseMediaItemJson)
.toList() ?? <MediaItem>[];
return RecommendationFolder(
@@ -45,25 +41,3 @@ class RecommendationFolder {
};
}
}
/// 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);
}
}
+24 -266
View File
@@ -22,7 +22,6 @@ import '../services/sync_service.dart';
import '../services/local_player_service.dart';
import '../services/metadata_service.dart';
import '../services/position_tracker.dart';
import '../services/group_volume_manager.dart';
import '../services/sendspin_service.dart';
import '../services/pcm_audio_player.dart';
import '../services/offline_action_queue.dart';
@@ -47,7 +46,6 @@ class MusicAssistantProvider with ChangeNotifier {
final DebugLogger _logger = DebugLogger();
final CacheService _cacheService = CacheService();
final PositionTracker _positionTracker = PositionTracker();
final GroupVolumeManager _groupVolumeManager = GroupVolumeManager();
MAConnectionState _connectionState = MAConnectionState.disconnected;
String? _serverUrl;
@@ -69,16 +67,6 @@ class MusicAssistantProvider with ChangeNotifier {
List<Player> _availablePlayers = [];
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
// Pending volume cache: prevents stale API data from overwriting optimistic
// volume updates in _updatePlayerState() and refreshPlayers().
// Key: playerId, Value: volume level (0-100)
final Map<String, int> _pendingVolumes = {};
final Map<String, int> _pendingVolumeTimestamps = {}; // millisecondsSinceEpoch
static const int _pendingVolumeTimeoutMs = 8000; // 8s for non-group players
static const int _pendingVolumeTimeoutGroupMs = 30000; // 30s for group players (API returns null)
static const int _pendingVolumeTolerancePoints = 3; // Confirm if within ±3 points
Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
String? _currentPodcastName; // Currently playing podcast's name (set when playing episode)
@@ -99,10 +87,6 @@ class MusicAssistantProvider with ChangeNotifier {
bool _isLocalPlayerPowered = true;
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
// Home refresh counter - increments to signal home screen to refresh all rows
int _homeRefreshCounter = 0;
String? _lastConnectedUrl; // Track last successfully connected server URL
StreamSubscription? _connectionStateSubscription;
StreamSubscription? _localPlayerEventSubscription;
StreamSubscription? _playerUpdatedEventSubscription;
@@ -513,9 +497,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Raw unfiltered players list (for internal use)
List<Player> get availablePlayersUnfiltered => _availablePlayers;
/// Group volume manager for resolving group player volumes
GroupVolumeManager get groupVolumeManager => _groupVolumeManager;
/// Check if a player should show the "manually synced" indicator (yellow border)
/// Returns true for BOTH the leader AND children of a manually created sync group
/// Excludes pre-configured MA speaker groups (provider = 'player_group')
@@ -593,9 +574,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Whether we have cached players available (for instant UI display on app resume)
bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
/// Home refresh counter - increments when home rows should refresh their data
int get homeRefreshCounter => _homeRefreshCounter;
/// Currently playing audiobook context (with chapters) - set when playing an audiobook
Audiobook? get currentAudiobook => _currentAudiobook;
@@ -1066,17 +1044,16 @@ class MusicAssistantProvider with ChangeNotifier {
_connectionStateSubscription?.cancel();
_connectionStateSubscription = _api!.connectionState.listen(
(state) async {
_connectionState = state;
notifyListeners();
if (state == MAConnectionState.connected) {
_logger.log('🔗 WebSocket connected to MA server');
if (_api!.authRequired && !_api!.isAuthenticated) {
// Don't broadcast 'connected' yet — AppStartup watches isConnected
// and would transition to HomeScreen before auth completes, causing
// data fetches to fail with "Not authenticated".
_logger.log('🔐 MA auth required, attempting authentication...');
final authenticated = await _handleMaAuthentication();
if (!authenticated) {
_connectionState = MAConnectionState.error;
_error = 'Authentication required. Please log in again.';
notifyListeners();
return;
@@ -1086,26 +1063,17 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
// No auth required — safe to broadcast connected and initialize
_connectionState = state;
notifyListeners();
// No auth required, initialize immediately
await _initializeAfterConnection();
} else if (state == MAConnectionState.authenticated) {
_connectionState = state;
notifyListeners();
_logger.log('✅ MA authentication successful');
// Now safe to initialize since we're authenticated
await _initializeAfterConnection();
} else if (state == MAConnectionState.disconnected) {
_connectionState = state;
notifyListeners();
// DON'T clear players or caches on disconnect!
// Keep showing cached data for instant UI display on reconnect
// Player list and state will be refreshed when connection is restored
_logger.log('📡 Disconnected - keeping cached players and data for instant resume');
} else {
_connectionState = state;
notifyListeners();
}
},
onError: (error) {
@@ -1368,17 +1336,6 @@ class MusicAssistantProvider with ChangeNotifier {
loadLibrary();
// Only force home refresh when connecting to a different server (or first connect).
// Reconnecting to the same server after backgrounding preserves cached home data.
if (_serverUrl != _lastConnectedUrl) {
_cacheService.invalidateHomeCache();
_homeRefreshCounter++;
}
_lastConnectedUrl = _serverUrl;
// Notify listeners to trigger UI refresh - home rows will re-fetch with valid authentication
notifyListeners();
// Process any queued offline actions now that we're connected
await _processOfflineQueue();
@@ -1914,9 +1871,6 @@ class MusicAssistantProvider with ChangeNotifier {
_tracks = [];
_currentTrack = null;
_cacheService.clearAll();
_groupVolumeManager.clear();
_pendingVolumes.clear();
_pendingVolumeTimestamps.clear();
_logger.log('🗑️ Cleared all data on logout');
notifyListeners();
}
@@ -2770,24 +2724,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
// Update member volume in _availablePlayers for accurate group volume
// computation. When MA changes a group member's volume (after setVolume
// on the group player), it fires player_updated for each member.
// Without this, _availablePlayers member volumes stay stale until the
// next _updatePlayerState poll, which could cause the group average
// to be wrong during the convergence check.
final rawVolume = event['volume_level'];
final eventVolume = rawVolume is int
? rawVolume
: (rawVolume is double ? rawVolume.round() : null);
if (eventVolume != null) {
final idx = _availablePlayers.indexWhere((p) => p.playerId == playerId);
if (idx != -1 && _availablePlayers[idx].volumeLevel != eventVolume) {
_availablePlayers[idx] =
_availablePlayers[idx].copyWith(volumeLevel: eventVolume);
}
}
final currentMedia = event['current_media'] as Map<String, dynamic>?;
final playerName = event['name'] as String? ?? playerId;
@@ -3167,9 +3103,6 @@ class MusicAssistantProvider with ChangeNotifier {
// ============================================================================
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Always fetch from API to ensure we have full album data with images
// Local/cached data is used by getCachedRecentAlbums() for instant display
if (_api == null) {
@@ -3201,9 +3134,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover artists');
return _cacheService.getCachedDiscoverArtists()!;
@@ -3226,9 +3156,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discover albums');
return _cacheService.getCachedDiscoverAlbums()!;
@@ -3250,79 +3177,30 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
Future<List<RecommendationFolder>> getDiscoveryFoldersWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
if (_cacheService.isDiscoveryFoldersCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached discovery folders');
return _cacheService.getCachedDiscoveryFolders()!;
Future<List<RecommendationFolder>> getExternalMixesWithCache({bool forceRefresh = false}) async {
if (_cacheService.isExternalMixesCacheValid(forceRefresh: forceRefresh)) {
_logger.log('📦 Using cached external mixes');
return _cacheService.getCachedExternalMixes()!;
}
if (_api == null) return _cacheService.getCachedDiscoveryFolders() ?? [];
if (_api == null) return _cacheService.getCachedExternalMixes() ?? [];
try {
_logger.log('🔄 Fetching discovery folders...');
_logger.log('🔄 Fetching external mixes...');
final recommendations = await _api!.getRecommendations();
final filteredFolders = _extractDiscoveryFolders(recommendations);
_cacheService.setCachedDiscoveryFolders(filteredFolders);
return filteredFolders;
final mixes = _extractExternalMixes(recommendations);
_cacheService.setCachedExternalMixes(mixes);
return mixes;
} catch (e) {
_logger.log('❌ Failed to fetch discovery folders: $e');
return _cacheService.getCachedDiscoveryFolders() ?? [];
_logger.log('❌ Failed to fetch external mixes: $e');
return _cacheService.getCachedExternalMixes() ?? [];
}
}
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cacheService.getCachedDiscoveryFolders();
/// Get items for a specific discovery folder (for individual row rendering)
Future<List<MediaItem>> getDiscoveryFolderItems(String folderId, {bool forceRefresh = false}) async {
final folders = await getDiscoveryFoldersWithCache(forceRefresh: forceRefresh);
final folder = folders.firstWhere(
(f) => f.itemId == folderId,
orElse: () => RecommendationFolder(
itemId: folderId,
provider: 'unknown',
name: '',
items: [],
),
);
return folder.items;
}
/// Get cached items for a specific discovery folder (for instant display)
List<MediaItem>? getCachedDiscoveryFolderItems(String folderId) {
final folders = getCachedDiscoveryFolders();
if (folders == null) return null;
final folder = folders.firstWhere(
(f) => f.itemId == folderId,
orElse: () => RecommendationFolder(
itemId: folderId,
provider: 'unknown',
name: '',
items: [],
),
);
return folder.items.isEmpty ? null : folder.items;
}
/// Refresh discovery folders by clearing cache and refetching from API
Future<void> refreshDiscoveryFolders() async {
_cacheService.invalidateHomeCache();
await getDiscoveryFoldersWithCache(forceRefresh: true);
}
List<RecommendationFolder> _extractDiscoveryFolders(List<RecommendationFolder> folders) {
List<RecommendationFolder> _extractExternalMixes(List<RecommendationFolder> folders) {
final result = <RecommendationFolder>[];
for (final folder in folders) {
// Filter out "recently played" (duplicate of MA's recently played)
final name = folder.name.trim().toLowerCase();
final id = folder.itemId.trim().toLowerCase();
final isRecentlyPlayed = name == 'recently played' || id.contains('recently_played');
if (isRecentlyPlayed) continue;
// Filter items by provider and type
final providerFiltered = filterByProvider(folder.items);
final filteredItems = providerFiltered
.where((item) => item.mediaType == MediaType.playlist || item.mediaType == MediaType.radio)
@@ -3336,7 +3214,7 @@ class MusicAssistantProvider with ChangeNotifier {
provider: folder.provider,
name: folder.name,
uri: folder.uri,
items: _deduplicateItems(filteredItems),
items: _deduplicateMixes(filteredItems),
));
}
@@ -3360,11 +3238,11 @@ class MusicAssistantProvider with ChangeNotifier {
return false;
}
List<MediaItem> _deduplicateItems(List<MediaItem> items) {
List<MediaItem> _deduplicateMixes(List<MediaItem> mixes) {
final Map<String, MediaItem> deduped = {};
for (final item in items) {
final key = '${item.provider}:${item.itemId}';
deduped.putIfAbsent(key, () => item);
for (final mix in mixes) {
final key = '${mix.provider}:${mix.itemId}';
deduped.putIfAbsent(key, () => mix);
}
return deduped.values.toList();
}
@@ -3382,6 +3260,9 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get cached discover albums synchronously (for instant display)
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
/// Get cached external mixes synchronously (for instant display)
List<RecommendationFolder>? getCachedExternalMixes() => _cacheService.getCachedExternalMixes();
/// Force a full library sync (for pull-to-refresh)
Future<void> forceLibrarySync() async {
if (_api == null) {
@@ -3485,9 +3366,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get audiobooks that have progress (continue listening) with caching
Future<List<Audiobook>> getInProgressAudiobooksWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
_logger.log('📦 Using cached in-progress audiobooks');
@@ -3527,9 +3405,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get random audiobooks for discovery with caching
Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) {
_logger.log('📦 Using cached discover audiobooks');
@@ -3566,9 +3441,6 @@ class MusicAssistantProvider with ChangeNotifier {
/// Get random series for discovery with caching
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
// Ensure home rows are loaded from database before checking cache
await _cacheService.ensureHomeRowsLoaded();
// Check cache first
if (!forceRefresh && _cacheService.isDiscoverSeriesCacheValid()) {
_logger.log('📦 Using cached discover series');
@@ -4611,40 +4483,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Apply volume protection for the selected player.
/// Injects effective volume for group players (whose API volume is null)
/// and protects optimistic volume updates from stale API data.
void _applyVolumeProtection(Player updatedPlayer, List<Player> allPlayers) {
if (_groupVolumeManager.isGroupPlayer(updatedPlayer)) {
final effectiveVolume = _groupVolumeManager.getEffectiveVolumeLevel(
updatedPlayer, allPlayers);
if (effectiveVolume != null) {
_selectedPlayer = _selectedPlayer!.copyWith(volumeLevel: effectiveVolume);
}
}
final pendingVol = _pendingVolumes[updatedPlayer.playerId];
final pendingTs = _pendingVolumeTimestamps[updatedPlayer.playerId];
if (pendingVol != null && pendingTs != null) {
final elapsed = DateTime.now().millisecondsSinceEpoch - pendingTs;
final isGroup = _groupVolumeManager.isGroupPlayer(updatedPlayer);
final timeoutMs = isGroup ? _pendingVolumeTimeoutGroupMs : _pendingVolumeTimeoutMs;
if (elapsed >= timeoutMs) {
_pendingVolumes.remove(updatedPlayer.playerId);
_pendingVolumeTimestamps.remove(updatedPlayer.playerId);
} else {
final currentEffective = _selectedPlayer?.volumeLevel;
if (currentEffective != null &&
(currentEffective - pendingVol).abs() <= _pendingVolumeTolerancePoints) {
_pendingVolumes.remove(updatedPlayer.playerId);
_pendingVolumeTimestamps.remove(updatedPlayer.playerId);
} else {
_selectedPlayer = _selectedPlayer!.copyWith(volumeLevel: pendingVol);
}
}
}
}
Future<void> _updatePlayerState() async {
if (_selectedPlayer == null || _api == null) return;
@@ -4677,24 +4515,6 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
// Update _availablePlayers with fresh volume/state data from API.
// This is critical for group volume computation: member players don't get
// individual _updatePlayerState() calls, so their volumes in _availablePlayers
// would go stale. Without this, after the pending timeout expires, the
// computed group average uses old member volumes and snaps back to 0.
for (final freshPlayer in allPlayers) {
final idx = _availablePlayers.indexWhere(
(p) => p.playerId == freshPlayer.playerId);
if (idx != -1) {
_availablePlayers[idx] = _availablePlayers[idx].copyWith(
volumeLevel: freshPlayer.volumeLevel,
state: freshPlayer.state,
powered: freshPlayer.powered,
available: freshPlayer.available,
);
}
}
Player? rawPlayer = allPlayers.firstWhere(
(p) => p.playerId == selectedId ||
(translatedSendspinId != null && p.playerId == translatedSendspinId) ||
@@ -4729,9 +4549,6 @@ class MusicAssistantProvider with ChangeNotifier {
);
_selectedPlayer = updatedPlayer;
_applyVolumeProtection(updatedPlayer, allPlayers);
stateChanged = true;
// Handle external sources (Spotify Connect, TV optical, etc.)
@@ -5080,8 +4897,6 @@ class MusicAssistantProvider with ChangeNotifier {
}
_selectedPlayer = updatedPlayer;
_applyVolumeProtection(updatedPlayer, _availablePlayers);
} catch (e) {
stateChanged = true;
}
@@ -6054,63 +5869,6 @@ class MusicAssistantProvider with ChangeNotifier {
Future<void> setVolume(String playerId, int volumeLevel) async {
try {
// Optimistically update the selected player's volume BEFORE any async calls.
// This prevents slider snapback: without this, clearing _pendingVolume on drag
// end causes the slider to read the stale player.volume until player_updated arrives.
if (_selectedPlayer != null && _selectedPlayer!.playerId == playerId) {
_selectedPlayer = _selectedPlayer!.copyWith(volumeLevel: volumeLevel);
notifyListeners();
}
// Cache pending volume for ALL players to protect optimistic updates
// from being overwritten by stale API data in _updatePlayerState()/refreshPlayers().
_pendingVolumes[playerId] = volumeLevel;
_pendingVolumeTimestamps[playerId] = DateTime.now().millisecondsSinceEpoch;
// Also cache for group players in GroupVolumeManager
// (MA API returns null for group player volume_level)
final player = _availablePlayers.where((p) => p.playerId == playerId).firstOrNull;
if (player != null && _groupVolumeManager.isGroupPlayer(player)) {
_groupVolumeManager.onVolumeSet(playerId, volumeLevel);
}
// Proportional volume propagation for in-app sync group leaders.
// When a user creates a group via long-press (players/cmd/group), the leader
// retains its own non-null volumeLevel and isGroupPlayer() returns false.
// We detect this via isGroupLeader and propagate volume changes to all
// members proportionally, preserving relative volume balance.
if (player != null &&
player.isGroupLeader &&
!_groupVolumeManager.isGroupPlayer(player)) {
final currentVol = player.volumeLevel;
final now = DateTime.now().millisecondsSinceEpoch;
for (final memberId in player.groupMembers!) {
if (memberId == playerId) continue; // Skip the leader itself
final idx = _availablePlayers.indexWhere((p) => p.playerId == memberId);
if (idx == -1) continue;
final member = _availablePlayers[idx];
if (member.volumeLevel == null) continue;
final int memberNewVol;
if (currentVol != null && currentVol > 0) {
// Ratio-based: maintain relative balance between members
memberNewVol = (member.volumeLevel! * volumeLevel / currentVol)
.round()
.clamp(0, 100);
} else {
// Can't ratio from 0 — set all to the new absolute value
memberNewVol = volumeLevel;
}
// Protect from snap-back and update optimistically
_pendingVolumes[memberId] = memberNewVol;
_pendingVolumeTimestamps[memberId] = now;
_availablePlayers[idx] = member.copyWith(volumeLevel: memberNewVol);
// Fire-and-forget — don't await, send in parallel
_api?.setVolume(memberId, memberNewVol);
}
}
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
if (builtinPlayerId != null && playerId == builtinPlayerId) {
_localPlayerVolume = volumeLevel;
+5 -29
View File
@@ -847,9 +847,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return Stack(
children: [
CustomScrollView(
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -937,7 +935,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -974,7 +972,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
),
),
Spacing.vGap8,
Spacing.vGap16,
// Album Description
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
InkWell(
@@ -1111,7 +1109,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 24),
],
),
),
@@ -1218,29 +1216,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
),
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.background.withOpacity(0.0),
colorScheme.background,
],
),
),
),
),
),
],
);
);
},
),
),
+7 -32
View File
@@ -882,13 +882,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
backgroundColor: colorScheme.background,
body: LayoutBuilder(
builder: (context, constraints) {
// Responsive cover size: 50% of screen width, clamped between 140-200 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.5).clamp(140.0, 200.0);
final expandedHeight = coverSize + 45;
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return Stack(
children: [
CustomScrollView(
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -944,7 +942,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -961,7 +959,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
),
),
),
Spacing.vGap8,
Spacing.vGap16,
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
InkWell(
onTap: () {
@@ -1159,30 +1157,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> with LibraryS
],
],
],
),
// Bottom fade gradient to mask content behind mini player
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.background.withOpacity(0.0),
colorScheme.background,
],
),
),
),
),
),
],
);
);
},
),
),
+60 -88
View File
@@ -14,7 +14,6 @@ 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';
@@ -298,13 +297,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
backgroundColor: colorScheme.surface,
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;
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
final expandedHeight = coverSize + 100;
return Stack(
children: [
CustomScrollView(
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -318,6 +315,49 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
},
color: colorScheme.onSurface,
),
actions: [
// Three-dot menu with sort/view options
Padding(
padding: const EdgeInsets.only(right: 8),
child: SizedBox(
height: 40,
width: 40,
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: 20,
color: Colors.white70,
),
),
),
),
),
],
flexibleSpace: FlexibleSpaceBar(
background: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -370,68 +410,22 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 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),
),
),
],
Text(
widget.authorName,
style: textTheme.headlineMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
// 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,
),
),
const SizedBox(height: 8),
Text(
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
@@ -454,28 +448,6 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
_buildAudiobookSliver(),
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,
],
),
),
),
),
),
],
);
},
),
+3 -27
View File
@@ -523,9 +523,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return Stack(
children: [
CustomScrollView(
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -654,7 +652,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -931,29 +929,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
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,
],
),
),
),
),
),
],
);
);
},
),
),
+2 -26
View File
@@ -283,9 +283,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return Stack(
children: [
CustomScrollView(
return CustomScrollView(
slivers: [
// App bar with series cover collage (matches audiobook detail screen)
SliverAppBar(
@@ -459,29 +457,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
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,
],
),
),
),
),
),
],
);
);
},
),
);
+1 -171
View File
@@ -1,11 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart';
import '../widgets/global_player_overlay.dart';
import 'new_home_screen.dart';
import 'new_library_screen.dart';
@@ -22,7 +19,6 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
final GlobalKey<SearchScreenState> _searchScreenKey = GlobalKey<SearchScreenState>();
DateTime? _lastBackPress;
final _cachedNavColor = _CachedNavColor();
@override
void initState() {
@@ -109,15 +105,7 @@ class _HomeScreenState extends State<HomeScreen> {
},
child: Scaffold(
backgroundColor: colorScheme.surface,
// extendBody: true preserves the existing layout where content fills the
// full screen and screens manage their own bottom padding (BottomSpacing).
// Without this, Scaffold would constrain the body above the nav bar,
// causing double bottom spacing.
extendBody: true,
// BottomNavigationBar requires an Overlay ancestor (for Tooltip);
// Scaffold inside Navigator provides one. Previously it was in
// GlobalPlayerOverlay's Stack (outside Navigator) causing crashes.
bottomNavigationBar: _buildBottomNavigationBar(context, colorScheme),
// No bottomNavigationBar here - it's now in GlobalPlayerOverlay
body: Stack(
children: [
// Home and Library use IndexedStack for state preservation
@@ -143,162 +131,4 @@ class _HomeScreenState extends State<HomeScreen> {
},
);
}
/// Build the bottom navigation bar with adaptive colors and player expansion animation.
/// Moved here from GlobalPlayerOverlay so it has proper Overlay ancestry
/// (required by BottomNavigationBar's internal Tooltip widgets).
Widget? _buildBottomNavigationBar(BuildContext context, ColorScheme colorScheme) {
// Hide nav bar when not connected
final isConnected = context.select<MusicAssistantProvider, bool>(
(p) => p.isConnected,
);
if (!isConnected) return null;
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
builder: (context, expansionState, _) {
// Nav bar color logic - only use adaptive colors when:
// 1. Player is expanding/expanded, OR
// 2. On a detail screen (isOnDetailScreen)
// On home screen with collapsed player: always use default theme colors
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
// Nav bar background color
final Color navBgColor;
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
// Player is expanding - blend from surface to player's adaptive color
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
} else if (useAdaptiveColors) {
// On a detail screen - use adaptive surface color for nav bar
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
navBgColor = adaptiveBg ?? colorScheme.surface;
} else {
// Home screen with collapsed player - use default surface color
navBgColor = colorScheme.surface;
}
// Icon color - only use adaptive colors when appropriate
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
? themeProvider.adaptiveColors!.primary
: colorScheme.primary;
// Blend icon color with player's primary color during expansion
Color sourceColor = baseSourceColor;
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Fade out and slide down nav bar as player expands
// Use IgnorePointer when faded to prevent accidental taps
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
return IgnorePointer(
ignoring: navOpacity < 0.1,
child: Transform.translate(
offset: Offset(0, navSlideDown),
child: Opacity(
opacity: navOpacity,
child: Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
// Clear adaptive colors when switching tabs (detail screen colors shouldn't persist)
themeProvider.clearAdaptiveColors();
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Symbols.book_2, fill: 0),
activeIcon: const Icon(Symbols.book_2, fill: 1),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
),
),
),
);
},
);
},
);
}
}
/// Cached color with contrast adjustment for nav bar icon colors.
/// Avoids expensive HSL conversions during scroll/animation.
class _CachedNavColor {
Color? _sourceColor;
bool? _isDark;
Color? _adjustedColor;
Color getAdjustedColor(Color sourceColor, bool isDark) {
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
return _adjustedColor!;
}
var navSelectedColor = sourceColor;
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
_sourceColor = sourceColor;
_isDark = isDark;
_adjustedColor = navSelectedColor;
return navSelectedColor;
}
}
+62 -262
View File
@@ -2,14 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../services/sync_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/player/mini_player_content.dart' show MiniPlayerLayout;
import '../widgets/player_selector.dart';
import '../widgets/album_row.dart';
import '../widgets/artist_row.dart';
@@ -19,7 +16,7 @@ import '../widgets/series_row.dart';
import '../widgets/playlist_row.dart';
import '../widgets/radio_station_row.dart';
import '../widgets/podcast_row.dart';
import '../widgets/discovery_row.dart';
import '../widgets/mixes_row.dart';
import '../widgets/common/disconnected_state.dart';
import 'settings_screen.dart';
import 'search_screen.dart';
@@ -31,14 +28,14 @@ class NewHomeScreen extends StatefulWidget {
State<NewHomeScreen> createState() => _NewHomeScreenState();
}
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin {
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
static final _logger = DebugLogger();
final ValueNotifier<int> _refreshSignal = ValueNotifier<int>(0);
double? _cachedRowHeight; // Cached to avoid size jumps when nav bar toggles
Key _refreshKey = UniqueKey();
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true;
bool _showExternalMixes = false;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
@@ -52,10 +49,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
bool _showDiscoverSeries = false;
// Row order (loaded from settings)
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
// Discovery folders (dynamic rows from provider recommendations)
List<RecommendationFolder> _discoveryFolders = [];
// Discovery row preferences (itemId -> enabled)
Map<String, bool> _discoveryRowEnabled = {};
@override
bool get wantKeepAlive => true;
@@ -65,82 +58,25 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
SyncStatus? _lastSyncStatus;
int _lastArtistCount = 0;
MusicAssistantProvider? _providerListeningTo;
int _lastHomeRefreshCounter = 0; // Track home refresh counter to force row refreshes
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadSettings();
// Listen to sync completion to refresh favorite rows when data becomes available
SyncService.instance.addListener(_onSyncChanged);
_lastSyncStatus = SyncService.instance.status;
_checkInitialFavoriteState();
// Always listen to provider for home refresh counter changes
final provider = context.read<MusicAssistantProvider>();
_lastHomeRefreshCounter = provider.homeRefreshCounter;
_providerListeningTo = provider;
provider.addListener(_onProviderChanged);
// Reload settings only when switching back to home from settings tab
navigationProvider.addListener(_onNavigationChanged);
}
int _lastNavIndex = 0;
void _onNavigationChanged() {
final newIndex = navigationProvider.selectedIndex;
// Reload preferences when leaving settings tab (3) for home tab (0)
if (_lastNavIndex == 3 && newIndex == 0) {
_reloadPreferencesOnly();
}
_lastNavIndex = newIndex;
}
/// Reload only local preferences (no API calls). Used when switching back from settings.
Future<void> _reloadPreferencesOnly() async {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
_showFavoritePlaylists = showFavPlaylists;
_showFavoriteRadioStations = showFavRadio;
_showFavoritePodcasts = showFavPodcasts;
_showContinueListeningAudiobooks = showContAudiobooks;
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
}
@override
void dispose() {
navigationProvider.removeListener(_onNavigationChanged);
SyncService.instance.removeListener(_onSyncChanged);
// Clean up provider listener
// Clean up provider listener if attached
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
_refreshSignal.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@@ -162,31 +98,21 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
});
}
/// Called when MusicAssistantProvider changes (for cache load detection and home refresh)
/// Called when MusicAssistantProvider changes (for cache load detection)
void _onProviderChanged() {
if (!mounted || _providerListeningTo == null) return;
final provider = _providerListeningTo!;
final currentRefreshCounter = provider.homeRefreshCounter;
// Check if home refresh counter changed (indicates authentication completed and cache was invalidated)
if (currentRefreshCounter != _lastHomeRefreshCounter) {
_logger.log('🔄 Home: refresh counter changed ($_lastHomeRefreshCounter -> $currentRefreshCounter), refreshing all rows');
_lastHomeRefreshCounter = currentRefreshCounter;
_refreshSignal.value++;
return; // Don't process other refresh conditions in this cycle
}
// Check for artists cache load (when favorites were empty on load)
if (_hadEmptyFavoritesOnLoad) {
final currentCount = provider.artists.length;
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_refreshSignal.value++;
}
_lastArtistCount = currentCount;
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
final currentCount = _providerListeningTo!.artists.length;
// If artists went from 0 to non-zero, refresh
if (_lastArtistCount == 0 && currentCount > 0) {
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
_hadEmptyFavoritesOnLoad = false;
_providerListeningTo!.removeListener(_onProviderChanged);
_providerListeningTo = null;
setState(() {
_refreshKey = UniqueKey();
});
}
_lastArtistCount = currentCount;
}
/// Called when SyncService status changes
@@ -202,17 +128,27 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_providerListeningTo?.removeListener(_onProviderChanged);
_providerListeningTo = null;
if (mounted) {
_refreshSignal.value++;
setState(() {
_refreshKey = UniqueKey();
});
}
}
_lastSyncStatus = newStatus;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Reload settings when app resumes (coming back from settings)
if (state == AppLifecycleState.resumed) {
_loadSettings();
}
}
Future<void> _loadSettings() async {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showExternalMixes = await SettingsService.getShowExternalMixes();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
@@ -223,12 +159,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
final rowOrder = await SettingsService.getHomeRowOrder();
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showExternalMixes = showExternalMixes;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
@@ -239,63 +175,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
_showDiscoverAudiobooks = showDiscAudiobooks;
_showDiscoverSeries = showDiscSeries;
_homeRowOrder = rowOrder;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
// Load discovery folders dynamically (after settings load)
// Skip if already loaded to avoid unnecessary rebuilds on settings reload
if (_discoveryFolders.isEmpty) {
await _loadDiscoveryFolders(forceRefresh: false);
} else {
// Just re-sync the row order with current enabled state (no API call)
setState(() {
_syncDiscoveryRowOrder(_discoveryFolders);
});
}
}
Future<void> _loadDiscoveryFolders({bool forceRefresh = false}) async {
if (!mounted) return;
final provider = context.read<MusicAssistantProvider>();
try {
var folders = await provider.getDiscoveryFoldersWithCache();
// If cached result is empty, force a refresh to get from API/database
if (folders.isEmpty && !forceRefresh) {
_logger.log('🔄 Discovery: Cached folders empty, forcing refresh...');
folders = await provider.getDiscoveryFoldersWithCache(forceRefresh: true);
}
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_syncDiscoveryRowOrder(folders);
});
}
} catch (e) {
_logger.log('⚠️ Failed to load discovery folders: $e');
}
}
/// Sync discovery row order without calling setState. Only adds new folders, never removes.
/// Visibility is controlled by _discoveryRowEnabled / _isRowEnabled().
bool _syncDiscoveryRowOrder(List<RecommendationFolder> folders) {
bool didModify = false;
for (final folder in folders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
_homeRowOrder.add(discoveryRowId);
didModify = true;
}
}
if (didModify) {
SettingsService.setHomeRowOrder(_homeRowOrder);
}
return didModify;
}
Future<void> _onRefresh() async {
@@ -310,7 +191,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
await _loadSettings();
if (mounted) {
_refreshSignal.value++;
setState(() {
_refreshKey = UniqueKey();
});
}
}
@@ -452,16 +335,11 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
/// Check if a specific row is enabled
bool _isRowEnabled(String rowId) {
// Discovery folders use the pattern 'discovery:{itemId}'
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
// Default to true if not explicitly set
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) {
case 'recent-albums': return _showRecentAlbums;
case 'discover-artists': return _showDiscoverArtists;
case 'discover-albums': return _showDiscoverAlbums;
case 'external-mixes': return _showExternalMixes;
case 'continue-listening': return _showContinueListeningAudiobooks;
case 'discover-audiobooks': return _showDiscoverAudiobooks;
case 'discover-series': return _showDiscoverSeries;
@@ -482,30 +360,19 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
builder: (context, constraints) {
// Each row is always 1/3 of screen height
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
// With extendBody: true, constraints include the area behind the nav bar.
// Only subtract mini player space (not nav bar) since nav bar is Scaffold chrome.
final miniPlayerSpace = MiniPlayerLayout.height + 12.0 + 22.0; // 72 + 12 + 22 = 106
final availableHeight = constraints.maxHeight - miniPlayerSpace;
final maxHeight = constraints.maxHeight.isFinite
? constraints.maxHeight
: MediaQuery.of(context).size.height;
final availableHeight = (maxHeight - BottomSpacing.withMiniPlayer).clamp(0.0, double.infinity);
// Account for margins between rows (2px each, 2 margins for 3 rows).
// Always use the 3-row margin calculation so rowHeight stays stable
// regardless of how many rows are currently enabled (avoids jitter on reload).
// Account for margins between rows (2px each)
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
const marginSize = 2.0;
final enabledRows = _countEnabledRows();
const marginsInView = 2 * marginSize; // always 2 margins for 3-row layout
final candidateRowHeight = (availableHeight - marginsInView) / 3;
// Cache rowHeight to prevent size jumps when the bottom nav bar toggles
// on connect/disconnect. The nav bar hides when disconnected, giving more
// vertical space and taller rows. When it reappears, rows shrink visibly.
// Fix: always ratchet down to the smaller (nav-bar-present) value, and
// only allow increases on large changes like screen rotation.
if (_cachedRowHeight == null || (candidateRowHeight - _cachedRowHeight!).abs() > 50) {
_cachedRowHeight = candidateRowHeight;
} else if (candidateRowHeight < _cachedRowHeight!) {
_cachedRowHeight = candidateRowHeight;
}
final rowHeight = _cachedRowHeight!;
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
final rawRowHeight = (availableHeight - marginsInView) / 3;
final rowHeight = rawRowHeight.isFinite && rawRowHeight > 0 ? rawRowHeight : 237.0;
// Use Android 12+ stretch overscroll effect
return NotificationListener<ScrollNotification>(
@@ -527,18 +394,11 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
behavior: const _StretchScrollBehavior(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
// For ≤3 rows: no padding needed (rows + miniPlayerSpace = viewport)
// For 4+ rows: pad by miniPlayerSpace so last row scrolls above mini player
padding: enabledRows >= 4
? EdgeInsets.only(bottom: miniPlayerSpace)
: EdgeInsets.zero,
child: SizedBox(
// Ensure minimum height for pull-to-refresh when empty
height: enabledRows == 0 ? availableHeight : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight, enabledRows == 0),
),
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
child: Column(
key: _refreshKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: _buildOrderedRows(provider, rowHeight),
),
),
),
@@ -548,51 +408,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
}
/// Build rows in the user's configured order
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight, bool isEmpty) {
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
final rows = <Widget>[];
// Show empty state with refresh hint when no rows are enabled
if (isEmpty) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
rows.add(
SizedBox.expand(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_rounded,
size: 64,
color: colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
S.of(context)!.noRowsEnabled,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
S.of(context)!.pullToRefreshHint,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.4),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
);
return rows;
}
for (final rowId in _homeRowOrder) {
final widget = _buildRowWidget(rowId, provider, rowHeight);
if (widget != null) {
@@ -617,7 +435,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedRecentAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'discover-artists':
@@ -627,7 +444,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(),
getCachedArtists: () => provider.getCachedDiscoverArtists(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'discover-albums':
@@ -637,7 +453,16 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'external-mixes':
if (!_showExternalMixes) return null;
return MixesRow(
key: const ValueKey('external-mixes'),
title: S.of(context)!.playlistMixes,
loadMixes: () => provider.getExternalMixesWithCache(),
getCachedMixes: () => provider.getCachedExternalMixes(),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
case 'continue-listening':
@@ -673,7 +498,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('favorite-albums'),
title: S.of(context)!.favoriteAlbums,
loadAlbums: () => provider.getFavoriteAlbums(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'favorite-artists':
@@ -682,7 +506,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
key: const ValueKey('favorite-artists'),
title: S.of(context)!.favoriteArtists,
loadArtists: () => provider.getFavoriteArtists(),
refreshSignal: _refreshSignal,
rowHeight: rowHeight,
);
case 'favorite-tracks':
@@ -721,29 +544,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
rowHeight: rowHeight,
);
default:
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final folderId = rowId.substring('discovery:'.length);
// Check per-row preference
final isEnabled = _discoveryRowEnabled[folderId] ?? false;
if (!isEnabled) return null;
try {
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == folderId,
);
return DiscoveryRow(
key: ValueKey(rowId),
title: folder.name,
loadItems: () => provider.getDiscoveryFolderItems(folderId),
getCachedItems: () => provider.getCachedDiscoveryFolderItems(folderId),
heroTagSuffix: 'home',
rowHeight: rowHeight,
);
} catch (e) {
// Folder not found, skip this row
}
}
return null;
}
}
+3 -41
View File
@@ -1506,7 +1506,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: _tabCount,
// Use default physics - custom physics may cause issues with Flutter 3.38
physics: const PageScrollPhysics(),
itemBuilder: (context, index) => _buildTabAtIndex(context, l10n, index),
itemBuilder: (context, index) => Builder(
builder: (context) => _buildTabAtIndex(context, l10n, index),
),
),
),
),
@@ -3410,17 +3412,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ PODCASTS TAB ============
// Consumer wrapper avoids using context.select inside PageView.builder's
// sliver context, which would rebuild the entire list instead of individual items.
Widget _buildPodcastsTab(BuildContext context, S l10n) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
return _buildPodcastsTabContent(context, l10n, maProvider);
},
);
}
Widget _buildPodcastsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// PERF: Use select() to only rebuild when podcasts, loading state, or enabled providers changes
@@ -3724,17 +3716,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ RADIO TAB ============
// Consumer wrapper avoids using context.select inside PageView.builder's
// sliver context, which would rebuild the entire list instead of individual items.
Widget _buildRadioStationsTab(BuildContext context, S l10n) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
return _buildRadioTabContent(context, l10n, maProvider);
},
);
}
Widget _buildRadioTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// PERF: Use select() to only rebuild when radio stations, loading state, or enabled providers changes
@@ -3984,17 +3966,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ ARTISTS TAB ============
// Consumer wrapper avoids using context.select inside PageView.builder's
// sliver context, which would rebuild the entire list instead of individual items.
Widget _buildArtistsTab(BuildContext context, S l10n) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
return _buildArtistsTabContent(context, l10n, maProvider);
},
);
}
Widget _buildArtistsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
// Use select to only rebuild when specific fields change
// SyncService changes are handled by _onSyncServiceChanged listener
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
@@ -4231,17 +4203,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ ALBUMS TAB ============
// Consumer wrapper avoids using context.select inside PageView.builder's
// sliver context, which would rebuild the entire list instead of individual items.
Widget _buildAlbumsTab(BuildContext context, S l10n) {
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, _) {
return _buildAlbumsTabContent(context, l10n, maProvider);
},
);
}
Widget _buildAlbumsTabContent(BuildContext context, S l10n, MusicAssistantProvider maProvider) {
// Use select to only rebuild when specific fields change
// SyncService changes are handled by _onSyncServiceChanged listener
final (isLoading, enabledProviders) = context.select<MusicAssistantProvider, (bool, Set<String>)>(
+22 -50
View File
@@ -797,9 +797,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return Stack(
children: [
CustomScrollView(
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -884,7 +882,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 12.0),
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1054,13 +1052,12 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
);
},
child: ListTile(
contentPadding: const EdgeInsets.only(left: 12, right: 20),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Track number
SizedBox(
width: 24,
width: 28,
child: Text(
'${index + 1}',
style: textTheme.bodyMedium?.copyWith(
@@ -1069,7 +1066,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
textAlign: TextAlign.right,
),
),
const SizedBox(width: 16),
const SizedBox(width: 12),
// Track artwork
ClipRRect(
borderRadius: BorderRadius.circular(4),
@@ -1118,28 +1115,25 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Padding(
padding: const EdgeInsets.only(right: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (HiResBadge.getTooltip(track) != null) ...[
HiResBadge.fromTrack(track, primaryColor: colorScheme.primary)!,
const SizedBox(width: 12),
],
),
if (track.duration != null)
SizedBox(
width: 40,
child: Text(
_formatDuration(track.duration!),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.right,
),
),
],
),
onTap: () => _playTrack(index),
),
@@ -1150,28 +1144,6 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
),
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
],
),
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,
],
),
),
),
),
),
],
);
},
),
+2 -26
View File
@@ -654,9 +654,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
final expandedHeight = coverSize + 70;
return Stack(
children: [
CustomScrollView(
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -738,7 +736,7 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -1111,28 +1109,6 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
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,
],
),
),
),
),
),
],
);
},
),
+2 -26
View File
@@ -255,9 +255,7 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
final coverSize = (constraints.maxWidth * 0.6).clamp(180.0, 280.0);
final expandedHeight = coverSize + 70;
return Stack(
children: [
CustomScrollView(
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: expandedHeight,
@@ -332,7 +330,7 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
// Header content with title, podcast name, metadata, and actions
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 24.0, 24.0),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -497,28 +495,6 @@ class _PodcastEpisodeDetailScreenState extends State<PodcastEpisodeDetailScreen>
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,
],
),
),
),
),
),
],
);
},
),
+33 -61
View File
@@ -108,9 +108,6 @@ class SearchScreenState extends State<SearchScreen> {
};
bool _isSearching = false;
bool _hasSearched = false;
// Race condition fix: Track the active search request ID
int _currentSearchId = 0;
bool _hasPendingSearch = false;
// PERF: Use ValueNotifier to avoid full screen rebuild on filter change
final ValueNotifier<String> _activeFilterNotifier = ValueNotifier('all');
String? _searchError;
@@ -362,8 +359,6 @@ class SearchScreenState extends State<SearchScreen> {
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(Timings.searchDebounce, () {
// Race condition fix: Check mounted before proceeding
if (!mounted) return;
_performSearch(query, keepFocus: true);
});
}
@@ -380,10 +375,6 @@ class SearchScreenState extends State<SearchScreen> {
return;
}
// Race condition fix: Generate unique ID for this search
final searchId = ++_currentSearchId;
_hasPendingSearch = true;
setState(() {
_isSearching = true;
_searchError = null;
@@ -409,11 +400,6 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
_logger.log('Global radio search failed: $e');
}
// Race condition fix: Abort if a newer search has started
if (!mounted || searchId != _currentSearchId) {
_logger.log('⏭️ Ignoring stale radio search results for "$query"');
return;
}
}
// 3. Combine and deduplicate by name
@@ -442,11 +428,6 @@ class SearchScreenState extends State<SearchScreen> {
} catch (e) {
_logger.log('Global podcast search failed: $e');
}
// Race condition fix: Abort if a newer search has started
if (!mounted || searchId != _currentSearchId) {
_logger.log('⏭️ Ignoring stale podcast search results for "$query"');
return;
}
}
// 3. Combine and deduplicate by name
@@ -461,55 +442,46 @@ class SearchScreenState extends State<SearchScreen> {
}
}
// Race condition fix: Only update results if this is still the latest search
if (!mounted) return;
if (searchId != _currentSearchId) {
_logger.log('⏭️ Ignoring stale search results for "$query"');
return;
}
if (mounted) {
// Combine all results
var combinedResults = {
...results,
'radios': allRadios.values.toList(),
'podcasts': allPodcasts.values.toList(),
};
// Combine all results
var combinedResults = {
...results,
'radios': allRadios.values.toList(),
'podcasts': allPodcasts.values.toList(),
};
// When "in library" mode is on, also filter by enabled providers
if (_libraryOnly) {
final enabledProviders = provider.enabledProviderIds.toSet();
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}
// When "in library" mode is on, also filter by enabled providers
if (_libraryOnly) {
final enabledProviders = provider.enabledProviderIds.toSet();
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}
setState(() {
_searchResults = combinedResults;
_isSearching = false;
_hasSearched = true;
_cachedListItems.clear(); // PERF: Clear cache on new results
_cachedAvailableFilters = null;
});
setState(() {
_searchResults = combinedResults;
_isSearching = false;
_hasSearched = true;
_hasPendingSearch = false;
_cachedListItems.clear(); // PERF: Clear cache on new results
_cachedAvailableFilters = null;
});
// Save to search history if we got results
final hasResults = results.values.any((list) => list.isNotEmpty);
if (hasResults && DatabaseService.instance.isInitialized) {
DatabaseService.instance.saveSearchQuery(query);
_loadRecentSearches(); // Refresh the list
}
// Save to search history if we got results
final hasResults = results.values.any((list) => list.isNotEmpty);
if (hasResults && DatabaseService.instance.isInitialized) {
DatabaseService.instance.saveSearchQuery(query);
_loadRecentSearches(); // Refresh the list
}
// Keep keyboard open if user is still typing
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
// Keep keyboard open if user is still typing
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
}
}
} catch (e) {
_logger.log('Search error: $e');
if (!mounted) return;
// Race condition fix: Only update error state if this is still the latest search
if (searchId == _currentSearchId) {
if (mounted) {
setState(() {
_isSearching = false;
_hasSearched = true;
_hasPendingSearch = false;
_searchError = S.of(context)!.searchFailed;
});
if (keepFocus && _focusNode.hasFocus) {
@@ -598,8 +570,8 @@ class SearchScreenState extends State<SearchScreen> {
),
const SizedBox(width: 8),
// Library-only toggle with circle background
LayoutBuilder(
builder: (context, constraints) {
Builder(
builder: (context) {
final control = SizedBox(
width: 44,
height: 44,
@@ -628,7 +600,7 @@ class SearchScreenState extends State<SearchScreen> {
),
);
// Only use Tooltip if an Overlay ancestor exists
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
final hasOverlay = Overlay.maybeOf(context) != null;
if (!hasOverlay) {
return control;
+19 -565
View File
@@ -1,10 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import '../l10n/app_localizations.dart';
import '../models/recommendation_folder.dart';
import '../providers/locale_provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart';
@@ -12,7 +8,6 @@ import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import '../theme/app_theme.dart';
import '../utils/page_transitions.dart';
import '../providers/navigation_provider.dart';
import '../widgets/global_player_overlay.dart';
import 'debug_log_screen.dart';
import 'login_screen.dart';
@@ -25,14 +20,13 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
final _scrollController = ScrollController();
final _lastFmApiKeyController = TextEditingController();
final _audioDbApiKeyController = TextEditingController();
String _appVersion = '';
// Main rows (default on)
bool _showRecentAlbums = true;
bool _showDiscoverArtists = true;
bool _showDiscoverAlbums = true;
bool _showExternalMixes = false;
// Favorites rows (default off)
bool _showFavoriteAlbums = false;
bool _showFavoriteArtists = false;
@@ -53,71 +47,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showHints = true;
// Display settings
bool _showProviderIcons = true;
// Discovery folders state
List<RecommendationFolder> _discoveryFolders = [];
Map<String, bool> _discoveryRowEnabled = {};
bool _isLoadingDiscoveryFolders = false;
/// All available static home screen rows (that can be toggled on/off)
static const List<String> _allAvailableRows = [
'recent-albums',
'discover-artists',
'discover-albums',
'discovery-mixes',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
/// Filtered row order for the draggable list
/// Shows all available rows (in home order + discovery rows), regardless of enabled state
List<String> get _filteredRowOrder {
final rows = <String>[];
// First, add all static rows in their current home order
for (final rowId in _homeRowOrder) {
if (rowId == 'discovery-mixes') continue;
rows.add(rowId);
}
// Then, add any static rows that aren't in home order yet (so users can enable them)
for (final rowId in _allAvailableRows) {
if (!_homeRowOrder.contains(rowId) && rowId != 'discovery-mixes') {
rows.add(rowId);
}
}
// Finally, add any discovery rows from provider that aren't in home order yet
for (final folder in _discoveryFolders) {
final discoveryRowId = 'discovery:${folder.itemId}';
if (!_homeRowOrder.contains(discoveryRowId)) {
rows.add(discoveryRowId);
}
}
return rows;
}
@override
void initState() {
super.initState();
_loadSettings();
_loadAppVersion();
}
Future<void> _loadAppVersion() async {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = '${info.version}+${info.buildNumber}';
});
}
}
Future<void> _loadSettings() async {
@@ -135,6 +69,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final showRecent = await SettingsService.getShowRecentAlbums();
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
final showExternalMixes = await SettingsService.getShowExternalMixes();
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
final showFavArtists = await SettingsService.getShowFavoriteArtists();
final showFavTracks = await SettingsService.getShowFavoriteTracks();
@@ -160,14 +95,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load display settings
final showProviderIcons = await SettingsService.getShowProviderIcons();
// Load discovery row preferences
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
_showDiscoverArtists = showDiscArtists;
_showDiscoverAlbums = showDiscAlbums;
_showExternalMixes = showExternalMixes;
_showFavoriteAlbums = showFavAlbums;
_showFavoriteArtists = showFavArtists;
_showFavoriteTracks = showFavTracks;
@@ -182,100 +115,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
_discoveryRowEnabled = discoveryRowPrefs;
});
}
// Load discovery folders after initial state is set
_loadDiscoveryFolders();
}
@override
void dispose() {
_scrollController.dispose();
_lastFmApiKeyController.dispose();
_audioDbApiKeyController.dispose();
super.dispose();
}
/// Load discovery folders from Music Assistant
Future<void> _loadDiscoveryFolders() async {
if (_discoveryFolders.isEmpty && mounted) {
setState(() {
_isLoadingDiscoveryFolders = true;
});
}
final provider = context.read<MusicAssistantProvider>();
try {
final folders = await provider.getDiscoveryFoldersWithCache();
// Also reload discovery row preferences to ensure they're up to date
final discoveryRowPrefs = await SettingsService.getDiscoveryRowPreferences();
if (mounted) {
setState(() {
_discoveryFolders = folders;
_discoveryRowEnabled = discoveryRowPrefs;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Refresh discovery folders from Music Assistant
Future<void> _refreshDiscoveryFolders() async {
final provider = context.read<MusicAssistantProvider>();
try {
setState(() {
_isLoadingDiscoveryFolders = true;
});
await provider.refreshDiscoveryFolders();
final folders = provider.getCachedDiscoveryFolders() ?? [];
if (mounted) {
setState(() {
_discoveryFolders = folders;
_isLoadingDiscoveryFolders = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingDiscoveryFolders = false;
});
}
}
}
/// Toggle a specific discovery row on/off
Future<void> _toggleDiscoveryRow(String itemId, bool enabled) async {
setState(() {
_discoveryRowEnabled[itemId] = enabled;
});
await SettingsService.setDiscoveryRowPreference(itemId, enabled);
}
// Helper to get row display info
Map<String, String> _getRowInfo(String rowId) {
final s = S.of(context)!;
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
final folder = _discoveryFolders.firstWhere(
(f) => f.itemId == itemId,
orElse: () => RecommendationFolder(
itemId: itemId,
provider: 'unknown',
name: rowId,
items: [],
),
);
return {'title': folder.name, 'subtitle': 'Discovery row from Music Assistant'};
}
switch (rowId) {
case 'recent-albums':
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
@@ -283,8 +136,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums':
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'discovery-mixes':
return {'title': s.discoveryMixes, 'subtitle': s.discoveryMixesDescription};
case 'external-mixes':
return {'title': s.playlistMixes, 'subtitle': s.showPlaylistMixes};
case 'continue-listening':
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks':
@@ -310,11 +163,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to get row enabled state
bool _getRowEnabled(String rowId) {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
return _discoveryRowEnabled[itemId] ?? false;
}
switch (rowId) {
case 'recent-albums':
return _showRecentAlbums;
@@ -322,6 +170,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return _showDiscoverArtists;
case 'discover-albums':
return _showDiscoverAlbums;
case 'external-mixes':
return _showExternalMixes;
case 'continue-listening':
return _showContinueListeningAudiobooks;
case 'discover-audiobooks':
@@ -348,20 +198,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to set row enabled state
void _setRowEnabled(String rowId, bool value) {
setState(() {
// Handle dynamic discovery folders
if (rowId.startsWith('discovery:')) {
final itemId = rowId.substring('discovery:'.length);
_discoveryRowEnabled[itemId] = value;
SettingsService.setDiscoveryRowPreference(itemId, value);
// Always add to home row order for position tracking
if (!_homeRowOrder.contains(rowId)) {
_homeRowOrder.add(rowId);
SettingsService.setHomeRowOrder(_homeRowOrder);
}
return;
}
// Save the setting
switch (rowId) {
case 'recent-albums':
_showRecentAlbums = value;
@@ -375,6 +211,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showDiscoverAlbums = value;
SettingsService.setShowDiscoverAlbums(value);
break;
case 'external-mixes':
_showExternalMixes = value;
SettingsService.setShowExternalMixes(value);
break;
case 'continue-listening':
_showContinueListeningAudiobooks = value;
SettingsService.setShowContinueListeningAudiobooks(value);
@@ -411,14 +251,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_showFavoritePodcasts = value;
SettingsService.setShowFavoritePodcasts(value);
break;
default:
break;
}
// When enabling a row, add it to home row order if not already present
if (value && !_homeRowOrder.contains(rowId)) {
_homeRowOrder.add(rowId);
SettingsService.setHomeRowOrder(_homeRowOrder);
}
});
}
@@ -453,13 +285,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () {
// Settings is rendered conditionally in HomeScreen (not pushed as a route),
// so Navigator.pop would pop the entire HomeScreen causing a black screen.
// Instead, switch tab back to Home and restore the mini player.
GlobalPlayerOverlay.showPlayer();
navigationProvider.setSelectedIndex(0);
},
onPressed: () => Navigator.pop(context),
color: colorScheme.onBackground,
),
title: Text(
@@ -485,7 +311,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
body: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -1031,7 +856,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 16),
// Reorderable home rows list (exclude discovery-mixes from list)
// Reorderable home rows list
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -1042,29 +867,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _filteredRowOrder.length,
itemCount: _homeRowOrder.length,
onReorder: (oldIndex, newIndex) {
// Create a mutable copy of the filtered list for reordering
final displayRows = List<String>.from(_filteredRowOrder);
if (newIndex > oldIndex) newIndex--;
final item = displayRows.removeAt(oldIndex);
displayRows.insert(newIndex, item);
setState(() {
// Update _homeRowOrder with the new order (all rows, including discovery)
_homeRowOrder = displayRows;
if (newIndex > oldIndex) newIndex--;
final item = _homeRowOrder.removeAt(oldIndex);
_homeRowOrder.insert(newIndex, item);
});
SettingsService.setHomeRowOrder(_homeRowOrder);
},
itemBuilder: (context, index) {
final rowId = _filteredRowOrder[index];
final rowId = _homeRowOrder[index];
final rowInfo = _getRowInfo(rowId);
final isEnabled = _getRowEnabled(rowId);
return Container(
key: ValueKey(rowId),
decoration: BoxDecoration(
border: index < _filteredRowOrder.length - 1
border: index < _homeRowOrder.length - 1
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
: null,
),
@@ -1195,149 +1015,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
// About & Support section
Text(
S.of(context)!.aboutAndSupport,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Support Development banner
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFFEA4AAA).withOpacity(0.15),
Color(0xFF1F6FEB).withOpacity(0.15),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.favorite_rounded,
color: Color(0xFFEA4AAA),
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
S.of(context)!.supportDevelopment,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(
S.of(context)!.ensembleIsUnofficial,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(height: 16),
Row(
children: [
// GitHub Sponsors button
Expanded(
child: _DonationButton(
label: S.of(context)!.sponsorOnGitHub,
color: Color(0xFF1F6FEB),
iconWidget: Icon(MdiIcons.github, size: 20),
onTap: () => _launchUrl('https://github.com/sponsors/CollotsSpot'),
),
),
const SizedBox(width: 12),
// Ko-fi button
Expanded(
child: _DonationButton(
icon: null,
label: S.of(context)!.buyMeAKoFi,
color: Color(0xFFFF5E5B),
iconWidget: Icon(Icons.coffee_rounded, color: Colors.white),
onTap: () => _launchUrl('https://ko-fi.com/collotsspot'),
),
),
],
),
],
),
),
const SizedBox(height: 16),
// About Ensemble card
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
S.of(context)!.aboutEnsemble,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
S.of(context)!.version,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.7),
),
),
const SizedBox(width: 4),
Text(
_appVersion,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
_LinkButton(
icon: Icons.code_rounded,
label: S.of(context)!.visitGitHubRepo,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble'),
),
const SizedBox(height: 8),
_LinkButton(
icon: Icons.bug_report_outlined,
label: S.of(context)!.reportABug,
onTap: () => _launchUrl('https://github.com/CollotsSpot/Ensemble/issues'),
),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
@@ -1415,227 +1092,4 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
/// Build the discovery section with individual row toggles
Widget _buildDiscoverySection(ColorScheme colorScheme, TextTheme textTheme) {
final s = S.of(context)!;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and refresh button
Row(
children: [
Icon(
Icons.explore_outlined,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
s.discoveryRows,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
),
if (_isLoadingDiscoveryFolders)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary.withOpacity(0.7),
),
),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _refreshDiscoveryFolders,
tooltip: s.refreshDiscoveryRows,
color: colorScheme.onSurfaceVariant,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
),
const SizedBox(height: 12),
// Discovery rows list
if (_discoveryFolders.isEmpty && !_isLoadingDiscoveryFolders)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
s.noDiscoveryRows,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
)
else
..._discoveryFolders.map((folder) {
final itemId = folder.itemId;
final isEnabled = _discoveryRowEnabled[itemId] ?? false;
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: SwitchListTile(
title: Text(
folder.name,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 14,
),
),
subtitle: folder.provider.isNotEmpty
? Text(
folder.provider,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 11,
),
)
: null,
value: isEnabled,
onChanged: (value) => _toggleDiscoveryRow(itemId, value),
activeColor: colorScheme.primary,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
dense: true,
),
);
}).toList(),
],
),
);
}
}
// Custom donation button widget
class _DonationButton extends StatelessWidget {
final String? icon;
final String label;
final Color color;
final VoidCallback onTap;
final Widget? iconWidget;
const _DonationButton({
this.icon,
required this.label,
required this.color,
required this.onTap,
this.iconWidget,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (iconWidget != null)
iconWidget!
else if (icon != null)
Image.asset(
icon!,
width: 20,
height: 20,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
);
}
}
// Custom link button widget
class _LinkButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _LinkButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Icon(
icon,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
label,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.primary,
),
),
const SizedBox(width: 4),
Icon(
Icons.open_in_new,
size: 14,
color: colorScheme.primary.withOpacity(0.7),
),
],
),
),
),
);
}
}
+28 -84
View File
@@ -1,7 +1,7 @@
import 'dart:convert';
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/recommendation_folder.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../services/database_service.dart';
import '../constants/timings.dart';
@@ -11,7 +11,6 @@ import '../constants/timings.dart';
class CacheService {
final DebugLogger _logger = DebugLogger();
bool _homeRowsLoaded = false;
Future<void>? _homeRowsLoadFuture;
// Cache size limits to prevent unbounded memory growth
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
@@ -22,14 +21,14 @@ class CacheService {
List<Album>? _cachedRecentAlbums;
List<Artist>? _cachedDiscoverArtists;
List<Album>? _cachedDiscoverAlbums;
List<RecommendationFolder>? _cachedDiscoveryFolders;
List<RecommendationFolder>? _cachedExternalMixes;
List<Audiobook>? _cachedInProgressAudiobooks;
List<Audiobook>? _cachedDiscoverAudiobooks;
List<AudiobookSeries>? _cachedDiscoverSeries;
DateTime? _recentAlbumsLastFetched;
DateTime? _discoverArtistsLastFetched;
DateTime? _discoverAlbumsLastFetched;
DateTime? _discoveryFoldersLastFetched;
DateTime? _externalMixesLastFetched;
DateTime? _inProgressAudiobooksLastFetched;
DateTime? _discoverAudiobooksLastFetched;
DateTime? _discoverSeriesLastFetched;
@@ -59,14 +58,6 @@ class CacheService {
// HOME SCREEN ROW CACHING
// ============================================================================
/// Ensure home rows are loaded from database before accessing cache
/// This ensures the cache is populated on first access if not already loaded
Future<void> ensureHomeRowsLoaded() async {
if (!_homeRowsLoaded) {
await loadHomeRowsFromDatabase();
}
}
/// Check if recent albums cache is valid
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
@@ -81,11 +72,6 @@ class CacheService {
/// Set cached recent albums
void setCachedRecentAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty recent albums result');
return;
}
_cachedRecentAlbums = albums;
_recentAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} recent albums');
@@ -107,11 +93,6 @@ class CacheService {
/// Set cached discover artists
void setCachedDiscoverArtists(List<Artist> artists) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (artists.isEmpty) {
_logger.log('⚠️ Not caching empty discover artists result');
return;
}
_cachedDiscoverArtists = artists;
_discoverArtistsLastFetched = DateTime.now();
_logger.log('✅ Cached ${artists.length} discover artists');
@@ -133,11 +114,6 @@ class CacheService {
/// Set cached discover albums
void setCachedDiscoverAlbums(List<Album> albums) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (albums.isEmpty) {
_logger.log('⚠️ Not caching empty discover albums result');
return;
}
_cachedDiscoverAlbums = albums;
_discoverAlbumsLastFetched = DateTime.now();
_logger.log('✅ Cached ${albums.length} discover albums');
@@ -145,30 +121,25 @@ class CacheService {
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
}
/// Check if discovery folders cache is valid
bool isDiscoveryFoldersCacheValid({bool forceRefresh = false}) {
/// Check if external mixes cache is valid
bool isExternalMixesCacheValid({bool forceRefresh = false}) {
if (forceRefresh) return false;
final now = DateTime.now();
return _cachedDiscoveryFolders != null &&
_discoveryFoldersLastFetched != null &&
now.difference(_discoveryFoldersLastFetched!) < Timings.homeRowCacheDuration;
return _cachedExternalMixes != null &&
_externalMixesLastFetched != null &&
now.difference(_externalMixesLastFetched!) < Timings.homeRowCacheDuration;
}
/// Get cached discovery folders
List<RecommendationFolder>? getCachedDiscoveryFolders() => _cachedDiscoveryFolders;
/// Get cached external mixes
List<RecommendationFolder>? getCachedExternalMixes() => _cachedExternalMixes;
/// Set cached discovery folders
void setCachedDiscoveryFolders(List<RecommendationFolder> folders) {
// Don't cache empty results (prevents persisting "not authenticated" errors)
if (folders.isEmpty) {
_logger.log('⚠️ Not caching empty discovery folders result');
return;
}
_cachedDiscoveryFolders = folders;
_discoveryFoldersLastFetched = DateTime.now();
_logger.log('✅ Cached ${folders.length} discovery folders');
/// Set cached external mixes
void setCachedExternalMixes(List<RecommendationFolder> mixes) {
_cachedExternalMixes = mixes;
_externalMixesLastFetched = DateTime.now();
_logger.log('✅ Cached ${mixes.length} external mix shelves');
// Persist to database for instant load on next launch
_persistHomeRowToDatabase('discovery_folders', folders.map((f) => f.toJson()).toList());
_persistHomeRowToDatabase('external_mixes', mixes.map((m) => m.toJson()).toList());
}
/// Check if in-progress audiobooks cache is valid
@@ -185,11 +156,6 @@ class CacheService {
/// Set cached in-progress audiobooks
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty in-progress audiobooks result');
return;
}
_cachedInProgressAudiobooks = audiobooks;
_inProgressAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
@@ -209,11 +175,6 @@ class CacheService {
/// Set cached discover audiobooks
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
// Don't cache empty results
if (audiobooks.isEmpty) {
_logger.log('⚠️ Not caching empty discover audiobooks result');
return;
}
_cachedDiscoverAudiobooks = audiobooks;
_discoverAudiobooksLastFetched = DateTime.now();
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
@@ -233,11 +194,6 @@ class CacheService {
/// Set cached discover series
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
// Don't cache empty results
if (series.isEmpty) {
_logger.log('⚠️ Not caching empty discover series result');
return;
}
_cachedDiscoverSeries = series;
_discoverSeriesLastFetched = DateTime.now();
_logger.log('✅ Cached ${series.length} discover series');
@@ -259,7 +215,7 @@ class CacheService {
_recentAlbumsLastFetched = null;
_discoverArtistsLastFetched = null;
_discoverAlbumsLastFetched = null;
_discoveryFoldersLastFetched = null;
_externalMixesLastFetched = null;
_inProgressAudiobooksLastFetched = null;
_discoverAudiobooksLastFetched = null;
_discoverSeriesLastFetched = null;
@@ -289,6 +245,8 @@ class CacheService {
_discoverArtistsLastFetched = null;
_cachedDiscoverAlbums = null;
_discoverAlbumsLastFetched = null;
_cachedExternalMixes = null;
_externalMixesLastFetched = null;
_cachedRecentAlbums = null;
_recentAlbumsLastFetched = null;
_searchCache.clear();
@@ -517,22 +475,10 @@ class CacheService {
// ============================================================================
/// Load home row data from database for instant display on startup
/// Returns the existing future if already loading, to prevent duplicate loads
Future<void> loadHomeRowsFromDatabase() async {
if (_homeRowsLoadFuture != null) {
// Already loading, return the existing future
return _homeRowsLoadFuture!;
}
if (_homeRowsLoaded) return;
_homeRowsLoaded = true;
// Create the load future
_homeRowsLoadFuture = _doLoadHomeRowsFromDatabase();
await _homeRowsLoadFuture;
_homeRowsLoadFuture = null; // Clear after completion
}
/// Internal implementation of home row loading
Future<void> _doLoadHomeRowsFromDatabase() async {
try {
if (!DatabaseService.instance.isInitialized) return;
@@ -581,24 +527,22 @@ class CacheService {
}
}
// Load discovery folders
final foldersData = await DatabaseService.instance.getHomeRowCache('discovery_folders');
if (foldersData != null) {
// Load external mixes
final mixesData = await DatabaseService.instance.getHomeRowCache('external_mixes');
if (mixesData != null) {
try {
final items = (jsonDecode(foldersData.itemsJson) as List)
final items = (jsonDecode(mixesData.itemsJson) as List)
.map((json) => RecommendationFolder.fromJson(json as Map<String, dynamic>))
.toList();
_cachedDiscoveryFolders = items;
_discoveryFoldersLastFetched = foldersData.lastUpdated;
_logger.log('📦 Loaded ${items.length} discovery folders from database');
_cachedExternalMixes = items;
_externalMixesLastFetched = mixesData.lastUpdated;
_logger.log('📦 Loaded ${items.length} external mix shelves from database');
} catch (e) {
_logger.log('⚠️ Failed to parse discovery folders: $e');
_logger.log('⚠️ Failed to parse external mixes: $e');
}
}
} catch (e) {
_logger.log('⚠️ Error loading home rows from database: $e');
} finally {
_homeRowsLoaded = true;
}
}
-175
View File
@@ -1,175 +0,0 @@
import '../models/player.dart';
import 'debug_logger.dart';
/// Manages volume state for group players where the MA API returns null volume.
///
/// Both pre-configured speaker groups (provider == 'player_group') and dynamic
/// sync groups (ID typically starts with 'syncgroup_') don't have their own
/// volume_level in the MA API. This manager computes an effective volume from
/// group members and caches recently-set volumes to prevent slider snap-back.
class GroupVolumeManager {
final _logger = DebugLogger();
// Cache of last-set volume per group player ID.
// Prevents slider snap-back between setVolume() and next player_updated event.
final Map<String, int> _pendingVolumes = {};
// Timestamps for pending volumes - used to expire stale entries
final Map<String, DateTime> _pendingTimestamps = {};
// How long to trust a pending volume before falling back to computed average.
// For group players the API never returns a volume, so we keep the pending
// value until the computed member average converges (within tolerance) or
// until a generous timeout to avoid leaking state forever.
static const Duration _pendingTimeout = Duration(seconds: 30);
// If the computed member average is within this many points of the pending
// value, we consider the server to have confirmed it and clear the pending.
static const int _confirmationTolerance = 3;
/// Check if a player is a group player that needs computed volume.
///
/// Detects both:
/// - Pre-configured MA speaker groups (provider == 'player_group')
/// - Dynamic sync groups (have group members but API returns null volume)
bool isGroupPlayer(Player player) {
// Pre-configured MA speaker groups
if (player.provider == 'player_group') return true;
// Dynamic sync groups: have multiple group members but MA returns null volume
if (player.groupMembers != null &&
player.groupMembers!.length > 1 &&
player.volumeLevel == null) {
return true;
}
return false;
}
/// Get the effective volume for a player.
///
/// For non-group players: returns player.volumeLevel (unchanged behavior).
/// For group players: returns cached pending volume if recent, otherwise
/// computes average from group members.
///
/// [player] - The player to get volume for.
/// [allPlayers] - All available players (needed to look up group members).
///
/// Returns volume as int 0-100, or null if no volume can be determined.
int? getEffectiveVolumeLevel(Player player, List<Player> allPlayers) {
if (!isGroupPlayer(player)) {
return player.volumeLevel;
}
// Check for a pending (just-set) volume
final pending = _pendingVolumes[player.playerId];
if (pending != null) {
final timestamp = _pendingTimestamps[player.playerId];
if (timestamp != null &&
DateTime.now().difference(timestamp) < _pendingTimeout) {
// Compute member average to see if server has converged
final memberAvg = _computeGroupVolume(player, allPlayers);
if (memberAvg != null && (memberAvg - pending).abs() <= _confirmationTolerance) {
// Server confirmed — member average matches what we set
_pendingVolumes.remove(player.playerId);
_pendingTimestamps.remove(player.playerId);
_logger.debug(
'GroupVolume: Convergence confirmed for ${player.name}: '
'pending=$pending, memberAvg=$memberAvg',
context: 'GroupVolumeManager');
return memberAvg;
}
// Not yet confirmed — keep showing the pending value
_logger.debug(
'GroupVolume: Pending protection active for ${player.name}: '
'pending=$pending, memberAvg=$memberAvg, '
'elapsed=${DateTime.now().difference(timestamp).inSeconds}s',
context: 'GroupVolumeManager');
return pending;
}
// Expired after generous timeout — remove it
_logger.debug(
'GroupVolume: Pending expired for ${player.name} after ${_pendingTimeout.inSeconds}s, '
'falling back to computed average',
context: 'GroupVolumeManager');
_pendingVolumes.remove(player.playerId);
_pendingTimestamps.remove(player.playerId);
}
// Compute average from group members
return _computeGroupVolume(player, allPlayers);
}
/// Compute the average volume across all group members.
/// Recursively resolves nested group members (MA sometimes makes the first
/// member of a group also a group player, which returns null for volumeLevel).
/// Returns null if no members have a valid volume.
int? _computeGroupVolume(Player groupPlayer, List<Player> allPlayers,
{int depth = 0}) {
// Prevent infinite recursion from circular group references
if (depth > 3) return null;
final memberIds = groupPlayer.groupMembers;
if (memberIds == null || memberIds.isEmpty) {
_logger.debug('GroupVolume: No group members for ${groupPlayer.name}',
context: 'GroupVolumeManager');
return null;
}
int totalVolume = 0;
int count = 0;
for (final memberId in memberIds) {
// Skip self-references (groupMembers can include the group's own ID)
if (memberId == groupPlayer.playerId) continue;
final member =
allPlayers.where((p) => p.playerId == memberId).firstOrNull;
if (member == null) continue;
if (member.volumeLevel != null) {
totalVolume += member.volumeLevel!;
count++;
} else if (isGroupPlayer(member)) {
// Nested group player — MA returns null volumeLevel for these.
// Recursively compute from the nested group's own members.
final subVolume =
_computeGroupVolume(member, allPlayers, depth: depth + 1);
if (subVolume != null) {
totalVolume += subVolume;
count++;
_logger.debug(
'GroupVolume: Resolved nested group ${member.name} → volume $subVolume',
context: 'GroupVolumeManager');
}
}
}
if (count == 0) {
_logger.debug(
'GroupVolume: No members with volume for ${groupPlayer.name} '
'(${memberIds.length} members checked, depth=$depth)',
context: 'GroupVolumeManager');
return null;
}
final avg = (totalVolume / count).round();
_logger.debug(
'GroupVolume: ${groupPlayer.name} average=$avg from $count members '
'(total=$totalVolume, depth=$depth)',
context: 'GroupVolumeManager');
return avg;
}
/// Record that a volume was just set on a group player.
/// This prevents the slider from snapping back to 0 before the
/// MA server processes the volume change on all members.
void onVolumeSet(String playerId, int volumeLevel) {
_pendingVolumes[playerId] = volumeLevel;
_pendingTimestamps[playerId] = DateTime.now();
}
/// Clear all cached state (e.g., on disconnect)
void clear() {
_pendingVolumes.clear();
_pendingTimestamps.clear();
}
}
-65
View File
@@ -7,11 +7,6 @@ import 'debug_logger.dart';
///
/// For MA players: intercept buttons and send volume commands via API
/// For builtin player: let native volume work (don't intercept)
///
/// Also provides a volume observer for lockscreen volume changes.
/// When the app is in the background/lockscreen, dispatchKeyEvent doesn't fire,
/// so the observer watches system STREAM_MUSIC volume changes and sends
/// absolute volume values to Flutter for routing to the MA player.
class HardwareVolumeService {
static final HardwareVolumeService _instance = HardwareVolumeService._internal();
factory HardwareVolumeService() => _instance;
@@ -22,25 +17,16 @@ class HardwareVolumeService {
final _volumeUpController = StreamController<void>.broadcast();
final _volumeDownController = StreamController<void>.broadcast();
final _absoluteVolumeController = StreamController<int>.broadcast();
Stream<void> get onVolumeUp => _volumeUpController.stream;
Stream<void> get onVolumeDown => _volumeDownController.stream;
/// Stream of absolute volume values (0-100) from lockscreen hardware buttons.
/// Fires when the system STREAM_MUSIC volume changes while the volume
/// observer is active (remote/group player selected, app in background).
Stream<int> get onAbsoluteVolumeChange => _absoluteVolumeController.stream;
bool _isListening = false;
bool get isListening => _isListening;
bool _isIntercepting = false;
bool get isIntercepting => _isIntercepting;
bool _isObservingVolume = false;
bool get isObservingVolume => _isObservingVolume;
/// Initialize the service and start listening for volume button events
Future<void> init() async {
if (_isListening) return;
@@ -53,11 +39,6 @@ class HardwareVolumeService {
case 'volumeDown':
_volumeDownController.add(null);
break;
case 'absoluteVolumeChange':
// Volume changed from lockscreen/background via ContentObserver
final volume = call.arguments as int? ?? 0;
_absoluteVolumeController.add(volume);
break;
}
});
@@ -89,53 +70,8 @@ class HardwareVolumeService {
}
}
/// Start the volume observer for lockscreen/background volume changes.
/// When active, system STREAM_MUSIC volume changes are detected and sent
/// to Flutter via [onAbsoluteVolumeChange]. Optionally sets the system
/// volume to [initialVolume] (0-100) to match the MA player's current volume.
Future<void> startVolumeObserver({int? initialVolume}) async {
if (_isObservingVolume) return;
try {
await _channel.invokeMethod('startVolumeObserver', {
if (initialVolume != null) 'initialVolume': initialVolume,
});
_isObservingVolume = true;
_logger.info('Volume observer started', context: 'VolumeService');
} catch (e) {
_logger.error('Failed to start volume observer', context: 'VolumeService', error: e);
}
}
/// Stop the volume observer.
Future<void> stopVolumeObserver() async {
if (!_isObservingVolume) return;
try {
await _channel.invokeMethod('stopVolumeObserver');
_isObservingVolume = false;
_logger.info('Volume observer stopped', context: 'VolumeService');
} catch (e) {
_logger.error('Failed to stop volume observer', context: 'VolumeService', error: e);
}
}
/// Sync the system volume to match the MA player volume (0-100).
/// Used to keep the system volume HUD in sync with the MA player.
Future<void> syncSystemVolume(int maVolume) async {
try {
await _channel.invokeMethod('syncSystemVolume', {'volume': maVolume});
} catch (e) {
_logger.error('Failed to sync system volume', context: 'VolumeService', error: e);
}
}
/// Stop listening for volume button events
Future<void> dispose() async {
if (_isObservingVolume) {
await stopVolumeObserver();
}
if (!_isListening) return;
try {
@@ -150,6 +86,5 @@ class HardwareVolumeService {
// Close StreamControllers to prevent memory leaks
await _volumeUpController.close();
await _volumeDownController.close();
await _absoluteVolumeController.close();
}
}
+28 -30
View File
@@ -215,7 +215,6 @@ class MusicAssistantAPI {
},
onDone: () {
_logger.log('WebSocket connection closed');
_isAuthenticated = false; // Reset auth state on disconnect
_updateConnectionState(MAConnectionState.disconnected);
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.completeError(Exception('Connection closed'));
@@ -414,7 +413,8 @@ class MusicAssistantAPI {
args: args,
);
final items = response['result'] as List<dynamic>?;
final items = response['result'] as List<dynamic>? ??
response['items'] as List<dynamic>?;
if (items == null) {
return [];
}
@@ -594,6 +594,28 @@ class MusicAssistantAPI {
}
}
// ============ RECOMMENDATIONS ============
/// Get recommendation folders (mixes, recommendations, etc.)
Future<List<RecommendationFolder>> getRecommendations() async {
try {
final response = await _sendCommand(
'music/recommendations',
);
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.whereType<Map<String, dynamic>>()
.map(RecommendationFolder.fromJson)
.toList();
} catch (e) {
_logger.log('Error getting recommendations: $e');
return [];
}
}
// ============ PODCASTS ============
/// Get all podcasts from the library
@@ -1196,28 +1218,6 @@ class MusicAssistantAPI {
}
}
// ============ RECOMMENDATIONS ============
/// Get recommendation folders (mixes, recommendations, etc.)
Future<List<RecommendationFolder>> getRecommendations() async {
try {
final response = await _sendCommand(
'music/recommendations',
);
final items = response['result'] as List<dynamic>?;
if (items == null) return [];
return items
.whereType<Map<String, dynamic>>()
.map(RecommendationFolder.fromJson)
.toList();
} catch (e) {
_logger.log('Error getting recommendations: $e');
return [];
}
}
/// Get the audiobookshelf provider instance ID
Future<String?> getAudiobookshelfProviderId() async {
try {
@@ -1466,10 +1466,9 @@ class MusicAssistantAPI {
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null})
.catchError((_) => <String, dynamic>{'result': null}));
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
final results = await Future.wait(futures);
final results = await Future.wait(futures, eagerError: false);
for (final trackResponse in results) {
if (albumUris.length >= limit) break;
@@ -1506,10 +1505,9 @@ class MusicAssistantAPI {
final futures = batch.map((uri) => _sendCommand(
'music/item_by_uri',
args: {'uri': uri},
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null})
.catchError((_) => <String, dynamic>{'result': null}));
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
final results = await Future.wait(futures);
final results = await Future.wait(futures, eagerError: false);
for (final albumResponse in results) {
try {
+1 -4
View File
@@ -657,10 +657,7 @@ class SendspinService {
void _updateState(SendspinConnectionState newState) {
if (_state != newState) {
_state = newState;
// Don't try to emit if disposed or controller is closed
if (!_isDisposed && !_stateController.isClosed) {
_stateController.add(newState);
}
_stateController.add(newState);
}
}
+15 -51
View File
@@ -94,7 +94,7 @@ class SettingsService {
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
static const String _keyShowDiscoveryFolders = 'show_discovery_folders';
static const String _keyShowExternalMixes = 'show_external_mixes';
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
static const String _keyShowDiscoverSeries = 'show_discover_series';
@@ -107,11 +107,21 @@ class SettingsService {
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
// Default row order (only these 3 rows enabled by default on first launch)
// Default row order
static const List<String> defaultHomeRowOrder = [
'recent-albums',
'discover-artists',
'discover-albums',
'external-mixes',
'continue-listening',
'discover-audiobooks',
'discover-series',
'favorite-albums',
'favorite-artists',
'favorite-tracks',
'favorite-playlists',
'favorite-radio-stations',
'favorite-podcasts',
];
// View Mode Settings
@@ -169,9 +179,6 @@ class SettingsService {
// Podcast Cover Cache (iTunes URLs for high-res artwork)
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
static const String _keyDiscoveryRowPreferences = 'discovery_row_preferences';
static Future<String?> getServerUrl() async {
final prefs = await _getPrefs();
return prefs.getString(_keyServerUrl);
@@ -386,9 +393,8 @@ class SettingsService {
static Future<bool> getShowDiscoverAlbums() => _getBool(_keyShowDiscoverAlbums, defaultValue: true);
static Future<void> setShowDiscoverAlbums(bool show) => _setBool(_keyShowDiscoverAlbums, show);
// Home Screen Discovery Folders (default off - provider mixes like Tidal)
static Future<bool> getShowDiscoveryFolders() => _getBool(_keyShowDiscoveryFolders, defaultValue: false);
static Future<void> setShowDiscoveryFolders(bool show) => _setBool(_keyShowDiscoveryFolders, show);
static Future<bool> getShowExternalMixes() => _getBool(_keyShowExternalMixes, defaultValue: false);
static Future<void> setShowExternalMixes(bool show) => _setBool(_keyShowExternalMixes, show);
// Home Screen Audiobook Rows (default off - optional)
static Future<bool> getShowContinueListeningAudiobooks() => _getBool(_keyShowContinueListeningAudiobooks, defaultValue: false);
@@ -938,7 +944,7 @@ class SettingsService {
/// for fine-grained control (10x more precise)
static Future<bool> getVolumePrecisionMode() async {
final prefs = await _getPrefs();
return prefs.getBool(_keyVolumePrecisionMode) ?? false;
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
}
/// Set whether volume precision mode is enabled
@@ -975,46 +981,4 @@ class SettingsService {
cache[podcastId] = url;
await setPodcastCoverCache(cache);
}
// Discovery Row Preferences (per-row settings for Music Assistant recommendations)
/// Get all discovery row preferences as a map (itemId -> enabled)
/// Defaults to all enabled (true) if not set
static Future<Map<String, bool>> getDiscoveryRowPreferences() async {
final prefs = await _getPrefs();
final json = prefs.getString(_keyDiscoveryRowPreferences);
if (json == null) return {}; // Empty map = all enabled by default
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return decoded.map((key, value) => MapEntry(key, value as bool));
} catch (_) {
return {};
}
}
/// Set all discovery row preferences at once
static Future<void> setDiscoveryRowPreferences(Map<String, bool> prefs) async {
final sharedPrefs = await _getPrefs();
await sharedPrefs.setString(_keyDiscoveryRowPreferences, jsonEncode(prefs));
}
/// Get whether a specific discovery row is enabled
/// Returns false if not explicitly set (default off)
static Future<bool> getDiscoveryRowPreference(String itemId) async {
final allPrefs = await getDiscoveryRowPreferences();
return allPrefs[itemId] ?? false; // Default to disabled
}
/// Set whether a specific discovery row is enabled
static Future<void> setDiscoveryRowPreference(String itemId, bool enabled) async {
final allPrefs = await getDiscoveryRowPreferences();
allPrefs[itemId] = enabled;
await setDiscoveryRowPreferences(allPrefs);
}
/// Clear all discovery row preferences (reverts to all enabled)
static Future<void> clearDiscoveryRowPreferences() async {
final prefs = await _getPrefs();
await prefs.remove(_keyDiscoveryRowPreferences);
}
}
+1 -3
View File
@@ -173,9 +173,7 @@ class SyncService with ChangeNotifier {
}
// Check if sync is needed (default: every 5 minutes)
// Always sync if cache is empty (no albums AND no artists loaded)
final cacheEmpty = _cachedAlbums.isEmpty && _cachedArtists.isEmpty;
if (!force && !cacheEmpty) {
if (!force) {
final albumsNeedSync = await _db.needsSync('albums', maxAge: const Duration(minutes: 5));
final artistsNeedSync = await _db.needsSync('artists', maxAge: const Duration(minutes: 5));
final audiobooksNeedSync = await _db.needsSync('audiobooks', maxAge: const Duration(minutes: 5));
+9 -14
View File
@@ -59,20 +59,15 @@ class _AlbumCardState extends State<AlbumCard> with LibraryStatusMixin {
@override
void initState() {
super.initState();
// Defer status initialization to after the build frame completes.
// setLibraryStatus() calls notifyListeners() synchronously, which triggers
// setState() in listening widgets — illegal during the build phase.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
});
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.album.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.album.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
_initFallbackImage();
}
+2 -18
View File
@@ -14,8 +14,6 @@ class AlbumRow extends StatefulWidget {
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Album>? Function()? getCachedAlbums;
/// Optional: signal to trigger in-place reload without destroying the widget
final ValueNotifier<int>? refreshSignal;
const AlbumRow({
super.key,
@@ -24,7 +22,6 @@ class AlbumRow extends StatefulWidget {
this.heroTagSuffix,
this.rowHeight,
this.getCachedAlbums,
this.refreshSignal,
});
@override
@@ -49,19 +46,6 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
_isLoading = false;
}
_loadAlbums();
widget.refreshSignal?.addListener(_onRefreshSignal);
}
void _onRefreshSignal() {
if (!mounted) return;
_hasLoaded = false;
_loadAlbums();
}
@override
void dispose() {
widget.refreshSignal?.removeListener(_onRefreshSignal);
super.dispose();
}
Future<void> _loadAlbums() async {
@@ -130,7 +114,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -174,7 +158,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+9 -14
View File
@@ -59,20 +59,15 @@ class _ArtistCardState extends State<ArtistCard> with LibraryStatusMixin {
@override
void initState() {
super.initState();
// Defer status initialization to after the build frame completes.
// setLibraryStatus() calls notifyListeners() synchronously, which triggers
// setState() in listening widgets — illegal during the build phase.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
});
// Initialize status in centralized service from widget data
final service = LibraryStatusService.instance;
final key = libraryItemKey;
if (!service.isInLibrary(key) && widget.artist.inLibrary) {
service.setLibraryStatus(key, true);
}
if (!service.isFavorite(key) && (widget.artist.favorite ?? false)) {
service.setFavoriteStatus(key, true);
}
// Fetch fallback image once in initState, not during build
_initFallbackImage();
}
+2 -18
View File
@@ -14,8 +14,6 @@ class ArtistRow extends StatefulWidget {
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<Artist>? Function()? getCachedArtists;
/// Optional: signal to trigger in-place reload without destroying the widget
final ValueNotifier<int>? refreshSignal;
const ArtistRow({
super.key,
@@ -24,7 +22,6 @@ class ArtistRow extends StatefulWidget {
this.heroTagSuffix,
this.rowHeight,
this.getCachedArtists,
this.refreshSignal,
});
@override
@@ -49,19 +46,6 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
_isLoading = false;
}
_loadArtists();
widget.refreshSignal?.addListener(_onRefreshSignal);
}
void _onRefreshSignal() {
if (!mounted) return;
_hasLoaded = false;
_loadArtists();
}
@override
void dispose() {
widget.refreshSignal?.removeListener(_onRefreshSignal);
super.dispose();
}
Future<void> _loadArtists() async {
@@ -130,7 +114,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
// Card layout: circle image + name below
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
@@ -170,7 +154,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -121,7 +121,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
);
}
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
@@ -162,7 +162,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 44.0;
const titleHeight = 52.0;
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
-282
View File
@@ -1,282 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'playlist_card.dart';
import 'radio_station_card.dart';
class DiscoveryRow extends StatefulWidget {
final String title;
final Future<List<MediaItem>> Function() loadItems;
final String? heroTagSuffix;
final double? rowHeight;
/// Optional: synchronous getter for cached data (for instant display)
final List<MediaItem>? Function()? getCachedItems;
const DiscoveryRow({
super.key,
required this.title,
required this.loadItems,
this.heroTagSuffix,
this.rowHeight,
this.getCachedItems,
});
@override
State<DiscoveryRow> createState() => _DiscoveryRowState();
}
class _DiscoveryRowState extends State<DiscoveryRow> with AutomaticKeepAliveClientMixin {
List<MediaItem> _items = [];
bool _isLoading = true;
bool _hasLoaded = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
// Get cached data synchronously BEFORE first build (no spinner flash)
final cached = widget.getCachedItems?.call();
if (cached != null && cached.isNotEmpty) {
_items = cached;
_isLoading = false;
}
_loadItems();
}
Future<void> _loadItems() async {
if (_hasLoaded) return;
_hasLoaded = true;
// Load fresh data (always update - fresh data may have images that cached data lacks)
try {
final freshItems = await widget.loadItems();
if (mounted && freshItems.isNotEmpty) {
setState(() {
_items = freshItems;
_isLoading = false;
});
// Pre-cache images for smooth hero animations
_precacheImages(freshItems);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
/// Pre-cache images so hero animations are smooth on first tap
void _precacheImages(List<MediaItem> items) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
// Only precache first ~10 visible items to avoid excessive network/memory use
final itemsToCache = items.take(10);
for (final item in itemsToCache) {
final imageUrl = maProvider.api?.getImageUrl(item, size: 256);
if (imageUrl != null) {
// Use CachedNetworkImageProvider to warm the cache
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) {
// Silently ignore precache errors
return false;
});
}
}
}
static final _logger = DebugLogger();
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
// Only show loading if we have no data at all
if (_items.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_items.isEmpty) {
return Center(
child: Text(
S.of(context)!.noAlbumsFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
);
}
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px subtitle = ~44px
const textAreaHeight = 44.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: _items.length,
itemExtent: itemExtent,
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
addRepaintBoundaries: false, // Cards already have RepaintBoundary
itemBuilder: (context, index) {
final item = _items[index];
final key = ValueKey(item.uri ?? item.itemId);
Widget card;
if (item is Playlist) {
card = PlaylistCard(
playlist: item,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else if (item.mediaType == MediaType.radio) {
card = RadioStationCard(
radioStation: item,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else {
// Fallback for other media types
card = _buildFallbackCard(item, cardWidth);
}
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: key,
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: card,
),
);
},
),
);
}
Widget _buildFallbackCard(MediaItem item, double width) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
width: width,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_getIconForMediaType(item.mediaType),
size: 48,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
const SizedBox(height: 8),
Text(
item.name,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
IconData _getIconForMediaType(MediaType type) {
switch (type) {
case MediaType.album:
return Icons.album;
case MediaType.artist:
return Icons.person;
case MediaType.track:
return Icons.music_note;
case MediaType.playlist:
return Icons.playlist_play;
case MediaType.radio:
return Icons.radio;
case MediaType.audiobook:
return Icons.book;
case MediaType.podcast:
return Icons.podcasts;
case MediaType.podcastEpisode:
return Icons.podcasts;
default:
return Icons.audiotrack;
}
}
@override
Widget build(BuildContext context) {
_logger.startBuild('DiscoveryRow:${widget.title}');
super.build(context); // Required for AutomaticKeepAliveClientMixin
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
child: SizedBox(
height: totalHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
widget.title,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: _buildContent(contentHeight, colorScheme),
),
],
),
),
);
_logger.endBuild('DiscoveryRow:${widget.title}');
return result;
}
}
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+4 -48
View File
@@ -140,10 +140,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
static const double _collapsedBorderRadius = 16.0;
static double get _collapsedArtSize => MiniPlayerLayout.artSize;
static const double _bottomNavHeight = 56.0;
// Animation for hiding bottom nav offset on detail screens
late AnimationController _detailScreenController;
bool _lastIsOnDetailScreen = false;
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
// Pastel yellow for grouped players (matches PlayerCard.groupBorderColor)
@@ -322,13 +318,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Track lyrics scroll position for swipe-to-close (only allowed at top)
_lyricsScrollController.addListener(_onLyricsScroll);
// Animation for transitioning mini player when entering/leaving detail screens
_detailScreenController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_detailScreenController.addListener(_onDetailScreenAnimation);
// Slide animation for device switching - used for snap/spring animations
_slideController = AnimationController(
duration: const Duration(milliseconds: 250),
@@ -367,10 +356,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_loadVolumePrecisionModeSetting();
}
void _onDetailScreenAnimation() {
if (mounted) setState(() {});
}
Future<void> _loadVolumePrecisionModeSetting() async {
final enabled = await SettingsService.getVolumePrecisionMode();
if (mounted) {
@@ -430,7 +415,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// (Controllers remove listeners on dispose, but explicit removal is clearer)
_controller.removeListener(_notifyExpansionProgress);
_controller.removeListener(_recordAnimationFrame);
_detailScreenController.removeListener(_onDetailScreenAnimation);
_lyricsScrollController.removeListener(_onLyricsScroll);
_controller.dispose();
@@ -438,7 +422,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
_sleepTimerPanelController.dispose();
_lyricsPanelController.dispose();
_slideController.dispose();
_detailScreenController.dispose();
_slideOffsetNotifier.dispose();
_positionSubscription?.cancel();
_queueItemsSubscription?.cancel();
@@ -1811,23 +1794,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
Widget build(BuildContext context) {
// PERF: Use select() to only rebuild when adaptive theme setting changes
final adaptiveTheme = context.select<ThemeProvider, bool>((p) => p.adaptiveTheme);
// Watch detail screen state to animate mini player position
final isOnDetailScreen = context.select<ThemeProvider, bool>((p) => p.isOnDetailScreen);
// Get full themeProvider for methods that need adaptive color schemes
final themeProvider = context.read<ThemeProvider>();
// Animate bottom nav offset when entering/leaving detail screens
if (isOnDetailScreen != _lastIsOnDetailScreen) {
_lastIsOnDetailScreen = isOnDetailScreen;
if (isOnDetailScreen) {
_detailScreenController.duration = const Duration(milliseconds: 100);
_detailScreenController.forward();
} else {
_detailScreenController.duration = const Duration(milliseconds: 80);
_detailScreenController.reverse();
}
}
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final selectedPlayer = maProvider.selectedPlayer;
@@ -1956,8 +1925,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
? adaptiveScheme.onPrimaryContainer
: colorScheme.onPrimaryContainer;
final effectiveNavHeight = _bottomNavHeight * (1.0 - _detailScreenController.value);
final bottomNavSpace = effectiveNavHeight + bottomPadding;
final bottomNavSpace = _bottomNavHeight + bottomPadding;
final bottomOffset = bottomNavSpace + _collapsedMargin;
final width = screenSize.width - (_collapsedMargin * 2);
@@ -2106,10 +2074,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
final textAlignment = Alignment.lerp(Alignment.centerLeft, Alignment.center, t)!;
final titleFontWeight = FontWeight.lerp(MiniPlayerLayout.primaryFontWeight, FontWeight.w600, t);
// Collapsed: position above bottom nav bar (or at bottom edge on detail screens)
// Collapsed: position above bottom nav bar
// Expanded: extend to very bottom (behind system nav bar) for seamless edge-to-edge
final effectiveNavHeight = _bottomNavHeight * (1.0 - _detailScreenController.value);
final bottomNavSpace = effectiveNavHeight + bottomPadding;
final bottomNavSpace = _bottomNavHeight + bottomPadding;
final collapsedBottomOffset = bottomNavSpace + _collapsedMargin;
final expandedBottomOffset = 0.0; // Extend behind system nav bar
final expandedHeight = screenSize.height; // Full screen height for edge-to-edge
@@ -2283,10 +2250,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Smoothly fade out horizontal slide offset (for device switching) as animation progresses
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
// Extend gesture hit area above mini player for easier swipe-down,
// especially on detail screens where the player sits closer to screen edge.
const _gestureExtension = 20.0;
return Positioned(
left: horizontalMargin,
right: horizontalMargin,
@@ -2512,12 +2475,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
});
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Invisible gesture extension area above mini player
if (!isExpanded) const SizedBox(height: _gestureExtension),
Container(
child: Container(
// PERF Phase 4: Only show shadow when meaningfully visible (t < 0.5)
// This avoids BoxDecoration allocation for majority of animation frames
// Shadow fades out quickly during first half of expansion
@@ -3457,8 +3415,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
),
),
],
),
),
);
}
+169 -7
View File
@@ -3,16 +3,51 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart';
import '../services/settings_service.dart';
import '../theme/theme_provider.dart';
import 'expandable_player.dart';
import 'player/mini_player_content.dart' show MiniPlayerLayout;
import 'player/player_reveal_overlay.dart';
/// Cached color with contrast adjustment
/// Avoids expensive HSL conversions during scroll
class _CachedNavColor {
Color? _sourceColor;
bool? _isDark;
Color? _adjustedColor;
Color getAdjustedColor(Color sourceColor, bool isDark) {
// Return cached value if inputs haven't changed
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
return _adjustedColor!;
}
// Compute new adjusted color
var navSelectedColor = sourceColor;
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
final hsl = HSLColor.fromColor(navSelectedColor);
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
}
// Cache the result
_sourceColor = sourceColor;
_isDark = isDark;
_adjustedColor = navSelectedColor;
return navSelectedColor;
}
}
final _cachedNavColor = _CachedNavColor();
/// A global key to access the player state from anywhere in the app
final globalPlayerKey = GlobalKey<ExpandablePlayerState>();
@@ -162,7 +197,6 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
// State for player reveal overlay
bool _isRevealVisible = false;
// State for interactive hint mode (blur backdrop + wait for user action)
bool _isHintModeActive = false;
Timer? _hintBounceTimer;
@@ -496,9 +530,138 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
padding: const EdgeInsets.only(bottom: 0), // Content manages its own padding
child: widget.child,
),
// BottomNavigationBar is now in HomeScreen's Scaffold.bottomNavigationBar
// for proper Navigator/Overlay ancestry
// Global persistent bottom navigation bar - positioned at bottom
// Hide when: not connected OR showing welcome screen
// Always show when connected so users can access Settings/Library even without a player
if (isConnected && !shouldShowWelcomeBackdrop)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ListenableBuilder(
listenable: navigationProvider,
builder: (context, _) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return ValueListenableBuilder<PlayerExpansionState>(
valueListenable: playerExpansionNotifier,
builder: (context, expansionState, _) {
// Nav bar color logic - only use adaptive colors when:
// 1. Player is expanding/expanded, OR
// 2. On a detail screen (isOnDetailScreen)
// On home screen with collapsed player: always use default theme colors
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
// Nav bar background color
final Color navBgColor;
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
// Player is expanding - blend from surface to player's adaptive color
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
} else if (useAdaptiveColors) {
// On a detail screen - use adaptive surface color for nav bar
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
navBgColor = adaptiveBg ?? colorScheme.surface;
} else {
// Home screen with collapsed player - use default surface color
navBgColor = colorScheme.surface;
}
// Icon color - only use adaptive colors when appropriate
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
? themeProvider.adaptiveColors!.primary
: colorScheme.primary;
// Blend icon color with player's primary color during expansion
Color sourceColor = baseSourceColor;
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
}
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
// Fade out and slide down nav bar as player expands
// Use IgnorePointer when faded to prevent accidental taps
final navOpacity = (1.0 - expansionState.progress * 2).clamp(0.0, 1.0);
final navSlideDown = expansionState.progress * 20; // Slide down 20px as it fades
return IgnorePointer(
ignoring: navOpacity < 0.1,
child: Transform.translate(
offset: Offset(0, navSlideDown),
child: Opacity(
opacity: navOpacity,
child: Container(
decoration: BoxDecoration(
color: navBgColor,
boxShadow: expansionState.progress < 0.5
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
]
: null,
),
child: BottomNavigationBar(
currentIndex: navigationProvider.selectedIndex,
onTap: (index) {
if (GlobalPlayerOverlay.isPlayerExpanded) {
GlobalPlayerOverlay.collapsePlayer();
}
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
// Clear adaptive colors when switching tabs (detail screen colors shouldn't persist)
themeProvider.clearAdaptiveColors();
if (index == 3) {
GlobalPlayerOverlay.hidePlayer();
} else if (navigationProvider.selectedIndex == 3) {
GlobalPlayerOverlay.showPlayer();
}
navigationProvider.setSelectedIndex(index);
},
backgroundColor: Colors.transparent,
selectedItemColor: navSelectedColor,
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
elevation: 0,
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: const Icon(Symbols.book_2, fill: 0),
activeIcon: const Icon(Symbols.book_2, fill: 1),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
),
),
),
);
},
);
},
);
},
),
),
// Blur backdrop for device selector (reveal mode) - static, no animation
if (_isRevealVisible && !_isHintModeActive)
Positioned.fill(
@@ -625,9 +788,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
PlayerRevealOverlay(
key: _revealKey,
onDismiss: _hidePlayerReveal,
// Use viewPadding to match BottomNavigationBar's height calculation.
// On detail screens the nav bar is hidden so exclude its height.
miniPlayerBottom: (context.read<ThemeProvider>().isOnDetailScreen ? 0.0 : BottomSpacing.navBarHeight) + MediaQuery.of(context).viewPadding.bottom + 12,
// Use viewPadding to match BottomNavigationBar's height calculation
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
miniPlayerHeight: 64,
showOnboardingHints: _isOnboardingReveal,
// Pass pending action parameters for "Play On" functionality
+1 -1
View File
@@ -108,7 +108,7 @@ class HiResBadge extends StatelessWidget {
),
);
// Only use Tooltip if an Overlay ancestor exists
// Avoid crash when Overlay isn't available (e.g., certain overlay contexts).
final hasOverlay = Overlay.maybeOf(context) != null;
if (!hasOverlay) {
return content;
+3 -11
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../services/library_status_service.dart';
/// A widget that rebuilds when the library/favorite status of an item changes.
@@ -124,16 +123,9 @@ mixin LibraryStatusMixin<T extends StatefulWidget> on State<T> {
void _onLibraryStatusChanged() {
// Trigger rebuild to pick up new status values
// Defer setState to avoid calling it during build phase
if (!mounted) return;
// Use Future.microtask to defer setState until after the current build phase
// This prevents "setState during build" errors while avoiding Overlay issues
Future.microtask(() {
if (mounted) {
setState(() {});
}
});
if (mounted) {
setState(() {});
}
}
/// Set library status optimistically and return previous value for rollback
+230
View File
@@ -0,0 +1,230 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../models/recommendation_folder.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
import 'playlist_card.dart';
import 'radio_station_card.dart';
class MixesRow extends StatefulWidget {
final String title;
final Future<List<RecommendationFolder>> Function() loadMixes;
final List<RecommendationFolder>? Function()? getCachedMixes;
final String? heroTagSuffix;
final double? rowHeight;
const MixesRow({
super.key,
required this.title,
required this.loadMixes,
this.getCachedMixes,
this.heroTagSuffix,
this.rowHeight,
});
@override
State<MixesRow> createState() => _MixesRowState();
}
class _MixesRowState extends State<MixesRow> with AutomaticKeepAliveClientMixin {
List<RecommendationFolder> _mixes = [];
bool _isLoading = true;
bool _hasLoaded = false;
static final _logger = DebugLogger();
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
final cached = widget.getCachedMixes?.call();
if (cached != null && cached.isNotEmpty) {
_mixes = cached;
_isLoading = false;
}
_loadMixes();
}
Future<void> _loadMixes() async {
if (_hasLoaded) return;
_hasLoaded = true;
try {
final freshMixes = await widget.loadMixes();
if (mounted && freshMixes.isNotEmpty) {
setState(() {
_mixes = freshMixes;
_isLoading = false;
});
_precacheMixImages(freshMixes);
}
} catch (e) {
// Silent failure - keep showing cached data
}
if (mounted && _isLoading) {
setState(() => _isLoading = false);
}
}
void _precacheMixImages(List<RecommendationFolder> mixes) {
if (!mounted) return;
final maProvider = context.read<MusicAssistantProvider>();
final itemsToCache = <MediaItem>[];
for (final folder in mixes) {
itemsToCache.addAll(folder.items);
if (itemsToCache.length >= 10) break;
}
for (final mix in itemsToCache.take(10)) {
final imageUrl = maProvider.api?.getImageUrl(mix, size: 256);
if (imageUrl != null) {
precacheImage(
CachedNetworkImageProvider(imageUrl),
context,
).catchError((_) => false);
}
}
}
Widget _buildShelfContent(List<MediaItem> items, double contentHeight) {
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
return ScrollConfiguration(
behavior: const _StretchScrollBehavior(),
child: ListView.builder(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
itemCount: items.length,
itemExtent: itemExtent,
cacheExtent: 500,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemBuilder: (context, index) {
final mix = items[index];
final key = ValueKey(mix.uri ?? mix.itemId);
Widget card;
if (mix is Playlist) {
card = PlaylistCard(
playlist: mix,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else if (mix.mediaType == MediaType.radio) {
card = RadioStationCard(
radioStation: mix,
heroTagSuffix: widget.heroTagSuffix,
imageCacheSize: 256,
);
} else {
card = const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Container(
key: key,
width: cardWidth,
margin: const EdgeInsets.symmetric(horizontal: 6.0),
child: card,
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
_logger.startBuild('MixesRow:${widget.title}');
super.build(context);
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final baseRowHeight = widget.rowHeight ?? 237.0;
const titleHeight = 52.0;
final contentHeight = baseRowHeight - titleHeight;
final visibleFolders = _mixes.where((folder) {
final name = folder.name.trim().toLowerCase();
final id = folder.itemId.trim().toLowerCase();
final isRecentlyPlayed = name == 'recently played' || id.contains('recently_played');
return !isRecentlyPlayed;
}).toList();
final result = RepaintBoundary(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_mixes.isEmpty && _isLoading)
SizedBox(
height: baseRowHeight - titleHeight,
child: const Center(child: CircularProgressIndicator()),
)
else if (_mixes.isEmpty || visibleFolders.isEmpty)
SizedBox(
height: baseRowHeight - titleHeight,
child: Center(
child: Text(
S.of(context)!.noPlaylistMixesFound,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
),
),
)
else
...List<Widget>.generate(visibleFolders.length * 2 - 1, (index) {
if (index.isOdd) return const SizedBox(height: 8.0);
final folder = visibleFolders[index ~/ 2];
return SizedBox(
height: baseRowHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Text(
folder.name,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
),
),
Expanded(
child: _buildShelfContent(folder.items, contentHeight),
),
],
),
);
}),
],
),
);
_logger.endBuild('MixesRow:${widget.title}');
return result;
}
}
class _StretchScrollBehavior extends ScrollBehavior {
const _StretchScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context, Widget child, ScrollableDetails details) {
return StretchingOverscrollIndicator(
axisDirection: details.direction,
child: child,
);
}
}
+10 -18
View File
@@ -100,12 +100,12 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
);
// Start reveal animation first, then load colors after it settles
// (color extraction triggers setState which causes jitter during animation)
// Preload all player track info and extract colors
_preloadColorsForPlayers();
// Start reveal animation
_revealController.duration = const Duration(milliseconds: 200);
_revealController.forward().then((_) {
if (mounted) _preloadColorsForPlayers();
});
_revealController.forward();
// Auto-refresh player data
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
@@ -266,23 +266,15 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
final hintColor = colorScheme.onSurface.withOpacity(0.7);
// Back gesture is handled at GlobalPlayerOverlay level
// Use Selector to only rebuild when the player list actually changes,
// not on every provider notification (prevents jitter during animation)
return Selector<MusicAssistantProvider, ({List<dynamic> players, String? selectedId, int playerCount})>(
selector: (_, p) => (
players: p.availablePlayers,
selectedId: p.selectedPlayer?.playerId,
playerCount: p.availablePlayers.length,
),
shouldRebuild: (prev, next) => prev.playerCount != next.playerCount || prev.selectedId != next.selectedId,
builder: (context, data, child) {
final maProvider = context.read<MusicAssistantProvider>();
final allPlayers = data.players;
return Consumer<MusicAssistantProvider>(
builder: (context, maProvider, child) {
final allPlayers = maProvider.availablePlayers;
final selectedPlayer = maProvider.selectedPlayer;
final currentTrack = maProvider.currentTrack;
// Filter out the selected player - it's already shown in the mini player
final players = allPlayers
.where((p) => p.playerId != data.selectedId)
.where((p) => p.playerId != selectedPlayer?.playerId)
.toList();
// Default card colors - used when player doesn't have extracted colors
+2
View File
@@ -806,12 +806,14 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
),
onPressed: () => _handleTransferQueue(context),
padding: Spacing.paddingAll12,
tooltip: S.of(context)!.transferQueueToPlayer,
),
// Clear queue button
IconButton(
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
onPressed: _handleClearQueue,
padding: Spacing.paddingAll12,
tooltip: S.of(context)!.clearQueue,
),
],
),
+2 -2
View File
@@ -107,7 +107,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px owner = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -152,7 +152,7 @@ class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClient
// Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -115,7 +115,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
// Card layout: square artwork + text below (same as AlbumRow)
// Text area: 8px gap + ~18px title + ~18px author = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12; // width + horizontal margins
@@ -160,7 +160,7 @@ class _PodcastRowState extends State<PodcastRow> with AutomaticKeepAliveClientMi
// Total row height includes title + content (same as AlbumRow)
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -107,7 +107,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
// Card layout: circle image + name below (same as ArtistRow)
// Text area: 8px gap + ~36px for 2-line name = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final imageSize = contentHeight - textAreaHeight;
final cardWidth = imageSize; // Card width = image width (circle)
final itemExtent = cardWidth + 16; // width + horizontal margins
@@ -148,7 +148,7 @@ class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAli
// Total row height includes title + content (same as ArtistRow)
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -179,7 +179,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
);
}
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize;
final itemExtent = cardWidth + 12;
@@ -231,7 +231,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
final maProvider = context.read<MusicAssistantProvider>();
final totalHeight = widget.rowHeight ?? 237.0;
const titleHeight = 44.0;
const titleHeight = 52.0;
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
+2 -2
View File
@@ -109,7 +109,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
// Card layout: square artwork + text below
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
const textAreaHeight = 44.0;
const textAreaHeight = 52.0;
final artworkSize = contentHeight - textAreaHeight;
final cardWidth = artworkSize; // Card width = artwork width (square)
final itemExtent = cardWidth + 12;
@@ -150,7 +150,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
// Total row height includes title + content
final totalHeight = widget.rowHeight ?? 224.0; // Default: 44 title + 180 content
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
const titleHeight = 52.0; // 12 top padding + ~24 text + 8 bottom padding
final contentHeight = totalHeight - titleHeight;
final result = RepaintBoundary(
-260
View File
@@ -1,260 +0,0 @@
# Pull Request: Group Volume Management, Hardware Volume Integration, and Bugfixes
## Summary
This PR represents a testing and debugging cycle following v3.0.2. Initial work on sync group volume management uncovered multiple UI bugs that required fixes. The final result addresses:
1. **Fix: Group Player Volume Management** - Two distinct volume control issues for Music Assistant sync groups
2. **Feature: Hardware Volume Button Integration** - Phone volume buttons now control Music Assistant player volume from lockscreen / shade widget / app minimised
3. **UI Bug Fixes** - Five UI issues discovered during sync group testing, which had to be fixed to get the sync groups to work without crashes
4. **Fix: Cold Start Homepage Race Condition** - Intermittent empty homepage on app cold start - surfaced once sync group volume calculations were introduced.
5. **Code Quality: Cleanup and Optimization** - Removes dead code and eliminates duplicate logic - mine, not yours, I tried to not to touch your code as much as possible
All changes have been tested and verified with `dart analyze` showing no new issues.
---
## Group Volume Management Fixes
### 1. Group Player Volume Management
**Issue:** Volume control for Music Assistant sync groups had two distinct problems depending on how the group was created:
1. **Server-side groups**: Volume slider would snap back to 0 immediately after release
2. **In-app created groups**: Adjusting the leader's volume only changed that one player, not all members proportionally
#### Part A: Server-Side Groups (Null Volume Snap-back)
**Root Cause:** The Music Assistant API returns `volume_level: null` for certain group players (pre-configured speaker groups with `provider == 'player_group'` and some server-side sync groups). The app had no logic to handle this case, so the null volume was interpreted as 0.
**Solution:** Implemented `GroupVolumeManager` service with dual-tier volume protection:
- **Tier 1 (GroupVolumeManager):** Computes effective volume from group members (detects groups, resolves nested, averages volumes, caches pending for 30s, confirms with ±3 tolerance).
- **Tier 2 (Provider-level):** Optimistic update protection (caches for 8s/30s, prevents stale overwrites).
#### Part B: In-App Created Groups (Proportional Volume Propagation)
**Root Cause:** For in-app groups, leader has non-null volume, so `setVolume()` only affects leader, not members.
**Expected behavior:** Leader adjustment changes all members proportionally; individual adjustments affect only that member.
**Solution:** In `setVolume()`, for group leaders with non-null volume:
1. Calculate proportional: `memberNewVol = (memberCurrentVol * newVol / leaderCurrentVol).round()` (handle zero case with absolute).
2. Cache pending, update optimistically, fire parallel API calls.
**Files Changed:**
- `lib/services/group_volume_manager.dart` (NEW - 162 lines)
- `lib/providers/music_assistant_provider.dart` (added manager, helper, updated events/state/volume methods)
---
## Features
### 2. Hardware Volume Button Integration (Convenience Feature)
**Feature:** Phone hardware volume buttons (including lockscreen) control MA player volume, with system HUD mirroring.
**Implementation:**
- **Android Native:** `MainActivity.kt` (~120 lines) - VolumeContentObserver, MethodChannel commands, lifecycle.
- **Flutter Service:** `hardware_volume_service.dart` (~65 lines) - streams, observer methods, sync.
- **Integration:** `main.dart` (~35 lines) - adjust/set/update methods, subscription.
**Why This Matters:** Seamless remote control from lockscreen/tray/buttons.
---
## UI Bug Fixes
During sync group testing, fixed these UI bugs:
### 3A. Zone Mismatch Warning
**File:** `lib/main.dart`
**Issue:** Console warnings during init.
**Root Cause:** Init code outside `runZonedGuarded()`.
**Fix:** Move init inside `runZonedGuarded()`.
---
### 3B. BottomNavigationBar "No Overlay" Crash
**Files:** `lib/screens/home_screen.dart`, `lib/widgets/global_player_overlay.dart`
**Issue:** Crash on nav interaction.
**Root Cause:** Nav in Stack without Overlay.
**Fix:** Move nav to Scaffold.bottomNavigationBar, add extendBody: true.
---
### 3C. setState During Build Warning (Artist/Album Cards)
**Files:** `lib/widgets/artist_card.dart`, `lib/widgets/album_card.dart`
**Issue:** Warnings causing corruption.
**Root Cause:** Service calls in initState trigger setState during build.
**Fix:** Defer with addPostFrameCallback, add mounted guard.
---
### 3D. context.select Inside SliverList Crash
**File:** `lib/screens/new_library_screen.dart`
**Issue:** Crash on library tabs.
**Root Cause:** context.select in sliver context.
**Fix:** Use Consumer wrappers for tabs.
---
### 3E. QueuePanel Tooltip "No Overlay" Error
**File:** `lib/widgets/player/queue_panel.dart`
**Issue:** Crash on long-press buttons.
**Root Cause:** Tooltips require Overlay, but in Stack.
**Fix:** Remove tooltip properties.
---
### 3F. Settings Back Button Crash
**File:** `lib/screens/settings_screen.dart`
**Issue:** Back button crashes to black screen.
**Root Cause:** Navigator.pop pops home screen.
**Fix:** Use showPlayer + setSelectedIndex(0).
---
## Cold Start Homepage Race Condition Fix
### 4. Cold Start Homepage Empty/No Refresh
**Issue:** Intermittent empty homepage on cold start.
**Root Cause:** isConnected true before auth complete, leading to failed fetches.
**Solution:** Delay broadcasting 'connected' state until auth if required (`music_assistant_provider.dart:1058-1101`).
---
## Technical Implementation Details
### GroupVolumeManager Architecture
The `GroupVolumeManager` is a stateful service that handles **server-side groups with null volume**:
1. **Detects group players:**
```dart
bool isGroupPlayer(Player player) {
// Pre-configured MA speaker groups
if (player.provider == 'player_group') return true;
// Dynamic sync groups: have multiple members but MA returns null volume
if (player.groupMembers != null &&
player.groupMembers!.length > 1 &&
player.volumeLevel == null) {
return true;
}
return false;
}
```
2. **Computes effective volume:**
- Recursively resolves nested groups (max depth 3)
- Averages volumes across all leaf members
- Skips self-references in `groupMembers`
- Returns `null` if no members have valid volumes
3. **Caches pending volumes:**
- When user sets volume: stored with timestamp
- Returns cached value if within 30s timeout
- Clears cache when member average converges (±3 tolerance)
- Falls back to computed average after timeout
4. **Integrates with provider:**
- Called from `_applyVolumeProtection()` in provider
- Receives fresh `allPlayers` list for accurate member volumes
- Injects effective volume into `_selectedPlayer` via `copyWith()`
### Proportional Volume Propagation (In-App Groups)
For **in-app created groups with non-null leader volume**, proportional propagation works as follows:
1. **Detection:** `setVolume()` checks if `player.isGroupLeader && !isGroupPlayer(player)`
- Leader has `groupMembers` with 2+ entries
- Leader has its own non-null `volumeLevel`
- NOT detected as a group player (GroupVolumeManager handles those)
2. **Calculation:** For each member in `groupMembers` (excluding leader itself):
```dart
if (leaderCurrentVol > 0) {
memberNewVol = (memberVol * newVol / leaderCurrentVol).round().clamp(0, 100)
} else {
memberNewVol = newVol // Can't ratio from zero
}
```
3. **Execution:**
- Cache `_pendingVolumes[memberId]` and `_pendingVolumeTimestamps[memberId]`
- Update `_availablePlayers[idx]` optimistically
- Fire `_api?.setVolume(memberId, memberNewVol)` without awaiting
- Continue to send leader's own `volume_set` command
4. **Result:** All member volumes scale proportionally with the leader, preserving relative balance.
### Connection State Flow (After Fix)
```
WebSocket connects → MAConnectionState.connected
↓
Auth required?
↙ ↘
YES NO
↓ ↓
Don't notify Set state + notify
Try auth ↓
↓ Initialize
Success?
↙ ↘
YES NO
↓ ↓
auth error state
event + notify
↓
Set state
+ notify
↓
Initialize
```
**Key:** Listeners only see `isConnected = true` after auth completes (or immediately if no auth required).
## Files Modified
android/.../MainActivity.kt | 120 + (NEW - ContentObserver)
lib/main.dart | 207 ±
lib/providers/music_assistant_provider.dart | 165 ±
lib/screens/home_screen.dart | 172 ± (BottomNav moved here)
lib/screens/new_library_screen.dart | 70 ± (Consumer wrappers)
lib/screens/settings_screen.dart | 9 ± (back button fix)
lib/services/group_volume_manager.dart | 162 + (NEW)
lib/services/hardware_volume_service.dart | 65 + (NEW)
lib/widgets/album_card.dart | 23 ± (postFrameCallback)
lib/widgets/artist_card.dart | 23 ± (postFrameCallback)
lib/widgets/global_player_overlay.dart | 171 - (BottomNav moved out)
lib/widgets/player/queue_panel.dart | 2 - (tooltip removed)
**Total:** ~650 added, ~300 removed/refactored.
+1 -33
View File
@@ -1106,30 +1106,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
@@ -1138,14 +1114,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
@@ -1284,4 +1252,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
flutter: ">=3.24.0"
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 3.0.3+55
version: 3.0.2+54
environment:
sdk: '>=3.0.0 <4.0.0'