Compare commits
56
Commits
+391
@@ -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
@@ -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.*
|
||||||
@@ -25,6 +25,8 @@ class Profiles extends Table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Recently played items - scoped by profile
|
/// Recently played items - scoped by profile
|
||||||
|
@TableIndex(name: 'idx_recently_played_profile', columns: {#profileUsername})
|
||||||
|
@TableIndex(name: 'idx_recently_played_profile_played', columns: {#profileUsername, #playedAt})
|
||||||
class RecentlyPlayed extends Table {
|
class RecentlyPlayed extends Table {
|
||||||
/// Auto-incrementing ID
|
/// Auto-incrementing ID
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
@@ -55,6 +57,8 @@ class RecentlyPlayed extends Table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cached library items for fast startup
|
/// Cached library items for fast startup
|
||||||
|
@TableIndex(name: 'idx_library_cache_type', columns: {#itemType})
|
||||||
|
@TableIndex(name: 'idx_library_cache_type_deleted', columns: {#itemType, #isDeleted})
|
||||||
class LibraryCache extends Table {
|
class LibraryCache extends Table {
|
||||||
/// Composite key: provider + item_id
|
/// Composite key: provider + item_id
|
||||||
TextColumn get cacheKey => text()();
|
TextColumn get cacheKey => text()();
|
||||||
@@ -139,6 +143,8 @@ class CachedPlayers extends Table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cached queue items for selected player
|
/// Cached queue items for selected player
|
||||||
|
@TableIndex(name: 'idx_cached_queue_player', columns: {#playerId})
|
||||||
|
@TableIndex(name: 'idx_cached_queue_player_position', columns: {#playerId, #position})
|
||||||
class CachedQueue extends Table {
|
class CachedQueue extends Table {
|
||||||
/// Auto-incrementing ID for ordering
|
/// Auto-incrementing ID for ordering
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
@@ -185,7 +191,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 4;
|
int get schemaVersion => 5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@@ -208,6 +214,18 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 4) {
|
if (from < 4) {
|
||||||
await m.createTable(searchHistory);
|
await m.createTable(searchHistory);
|
||||||
}
|
}
|
||||||
|
// Migration from v4 to v5: Add indexes for query performance
|
||||||
|
if (from < 5) {
|
||||||
|
// RecentlyPlayed indexes
|
||||||
|
await customStatement('CREATE INDEX IF NOT EXISTS idx_recently_played_profile ON recently_played (profile_username)');
|
||||||
|
await customStatement('CREATE INDEX IF NOT EXISTS idx_recently_played_profile_played ON recently_played (profile_username, played_at)');
|
||||||
|
// LibraryCache indexes
|
||||||
|
await customStatement('CREATE INDEX IF NOT EXISTS idx_library_cache_type ON library_cache (item_type)');
|
||||||
|
await customStatement('CREATE INDEX IF NOT EXISTS idx_library_cache_type_deleted ON library_cache (item_type, is_deleted)');
|
||||||
|
// CachedQueue indexes
|
||||||
|
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player ON cached_queue (player_id)');
|
||||||
|
await customStatement('CREATE INDEX IF NOT EXISTS idx_cached_queue_player_position ON cached_queue (player_id, position)');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -287,5 +287,6 @@
|
|||||||
"showHints": "Hinweise anzeigen",
|
"showHints": "Hinweise anzeigen",
|
||||||
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
||||||
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
|
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
|
||||||
"holdToSync": "Gerät gedrückt halten zum Synchronisieren"
|
"holdToSync": "Gedrückt halten zum Sync",
|
||||||
|
"swipeToAdjustVolume": "Wischen für Lautstärke"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -1273,11 +1273,16 @@
|
|||||||
"description": "Hint shown when mini player bounces"
|
"description": "Hint shown when mini player bounces"
|
||||||
},
|
},
|
||||||
|
|
||||||
"holdToSync": "Tap and hold a player to sync",
|
"holdToSync": "Long-press to sync",
|
||||||
"@holdToSync": {
|
"@holdToSync": {
|
||||||
"description": "Hint for long-press to sync players"
|
"description": "Hint for long-press to sync players"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"swipeToAdjustVolume": "Swipe to adjust volume",
|
||||||
|
"@swipeToAdjustVolume": {
|
||||||
|
"description": "Hint for swiping left/right to adjust player volume"
|
||||||
|
},
|
||||||
|
|
||||||
"selectPlayerHint": "Choose a player, or dismiss by swiping down",
|
"selectPlayerHint": "Choose a player, or dismiss by swiping down",
|
||||||
"@selectPlayerHint": {
|
"@selectPlayerHint": {
|
||||||
"description": "First-time hint for selecting a player in device selector"
|
"description": "First-time hint for selecting a player in device selector"
|
||||||
|
|||||||
+2
-1
@@ -483,5 +483,6 @@
|
|||||||
"showHints": "Mostrar Sugerencias",
|
"showHints": "Mostrar Sugerencias",
|
||||||
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
||||||
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
|
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
|
||||||
"holdToSync": "Mantén presionado un dispositivo para sincronizar"
|
"holdToSync": "Mantén presionado para sync",
|
||||||
|
"swipeToAdjustVolume": "Desliza para ajustar volumen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ Future<void> main() async {
|
|||||||
// Migrate existing ownerName to profile (one-time for existing users)
|
// Migrate existing ownerName to profile (one-time for existing users)
|
||||||
await ProfileService.instance.migrateFromOwnerName();
|
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
|
// Load library from cache for instant startup
|
||||||
await SyncService.instance.loadFromCache();
|
await SyncService.instance.loadFromCache();
|
||||||
_logger.log('📦 Library cache loaded');
|
_logger.log('📦 Library cache loaded');
|
||||||
|
|||||||
+25
-7
@@ -87,8 +87,31 @@ class Player {
|
|||||||
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
|
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
|
||||||
bool get isGroupChild => syncedTo != null;
|
bool get isGroupChild => syncedTo != null;
|
||||||
|
|
||||||
|
// A player is manually synced if it's synced TO another player (child of a sync group)
|
||||||
|
// This excludes pre-configured MA speaker groups which have groupMembers but no syncedTo
|
||||||
|
// Used for yellow border highlight - only shows for players the user manually synced
|
||||||
|
bool get isManuallySynced => syncedTo != null;
|
||||||
|
|
||||||
// Track when this Player object was created (for local interpolation fallback)
|
// Track when this Player object was created (for local interpolation fallback)
|
||||||
static final Map<String, double> _playerCreationTimes = {};
|
static final Map<String, double> _playerCreationTimes = {};
|
||||||
|
static const int _maxCreationTimesEntries = 50;
|
||||||
|
|
||||||
|
/// Clean up old creation time entries using LRU eviction
|
||||||
|
static void _cleanupCreationTimes() {
|
||||||
|
if (_playerCreationTimes.length <= _maxCreationTimesEntries) return;
|
||||||
|
|
||||||
|
// Sort by timestamp value (oldest first) for true LRU eviction
|
||||||
|
final sortedEntries = _playerCreationTimes.entries.toList()
|
||||||
|
..sort((a, b) => a.value.compareTo(b.value));
|
||||||
|
|
||||||
|
// Remove oldest entries until we're at target size
|
||||||
|
final entriesToRemove = sortedEntries.take(
|
||||||
|
_playerCreationTimes.length - _maxCreationTimesEntries,
|
||||||
|
);
|
||||||
|
for (final entry in entriesToRemove) {
|
||||||
|
_playerCreationTimes.remove(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate current elapsed time (interpolated if playing)
|
// Calculate current elapsed time (interpolated if playing)
|
||||||
double get currentElapsedTime {
|
double get currentElapsedTime {
|
||||||
@@ -130,13 +153,8 @@ class Player {
|
|||||||
if (!_playerCreationTimes.containsKey(creationKey)) {
|
if (!_playerCreationTimes.containsKey(creationKey)) {
|
||||||
// First time seeing this elapsed_time - record when we saw it
|
// First time seeing this elapsed_time - record when we saw it
|
||||||
_playerCreationTimes[creationKey] = now;
|
_playerCreationTimes[creationKey] = now;
|
||||||
// Clean up old entries to prevent memory leak
|
// Clean up old entries to prevent memory leak (LRU eviction)
|
||||||
if (_playerCreationTimes.length > 100) {
|
_cleanupCreationTimes();
|
||||||
final keysToRemove = _playerCreationTimes.keys.take(50).toList();
|
|
||||||
for (final key in keysToRemove) {
|
|
||||||
_playerCreationTimes.remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final creationTime = _playerCreationTimes[creationKey]!;
|
final creationTime = _playerCreationTimes[creationKey]!;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Player state
|
// Player state
|
||||||
Player? _selectedPlayer;
|
Player? _selectedPlayer;
|
||||||
List<Player> _availablePlayers = [];
|
List<Player> _availablePlayers = [];
|
||||||
|
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
|
||||||
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
|
||||||
Track? _currentTrack;
|
Track? _currentTrack;
|
||||||
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
|
||||||
@@ -67,6 +68,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
bool _isLocalPlayerPowered = true;
|
bool _isLocalPlayerPowered = true;
|
||||||
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100)
|
||||||
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
|
bool _builtinPlayerAvailable = true; // False on MA 2.7.0b20+ (uses Sendspin instead)
|
||||||
|
StreamSubscription? _connectionStateSubscription;
|
||||||
StreamSubscription? _localPlayerEventSubscription;
|
StreamSubscription? _localPlayerEventSubscription;
|
||||||
StreamSubscription? _playerUpdatedEventSubscription;
|
StreamSubscription? _playerUpdatedEventSubscription;
|
||||||
StreamSubscription? _playerAddedEventSubscription;
|
StreamSubscription? _playerAddedEventSubscription;
|
||||||
@@ -131,6 +133,69 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return _availablePlayers;
|
return _availablePlayers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a player should show the "manually synced" indicator (yellow border)
|
||||||
|
/// Returns true for BOTH the leader AND children of a manually created sync group
|
||||||
|
/// Excludes pre-configured MA speaker groups (provider = 'player_group')
|
||||||
|
bool isPlayerManuallySynced(String playerId) {
|
||||||
|
final player = _availablePlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||||
|
if (player == null) return false;
|
||||||
|
|
||||||
|
// Group players (like "All Speakers") should NEVER have yellow border
|
||||||
|
// They are pre-configured containers, not manually synced players
|
||||||
|
// Check this FIRST before any other logic to prevent edge cases
|
||||||
|
if (player.provider == 'player_group') return false;
|
||||||
|
|
||||||
|
// Case 1: Player is a child synced to another player
|
||||||
|
if (player.syncedTo != null) {
|
||||||
|
// Look up sync target - also check translated IDs for Cast+Sendspin players
|
||||||
|
// The syncedTo might contain a Cast ID but the player list has the Sendspin version
|
||||||
|
Player? syncTarget = _availablePlayers.where((p) => p.playerId == player.syncedTo).firstOrNull;
|
||||||
|
|
||||||
|
// If not found, try looking up by translated Sendspin ID
|
||||||
|
if (syncTarget == null) {
|
||||||
|
final translatedId = _castToSendspinIdMap[player.syncedTo];
|
||||||
|
if (translatedId != null) {
|
||||||
|
syncTarget = _availablePlayers.where((p) => p.playerId == translatedId).firstOrNull;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check reverse: syncedTo might be Sendspin ID, look for Cast player
|
||||||
|
if (syncTarget == null) {
|
||||||
|
// Build reverse map on demand
|
||||||
|
for (final entry in _castToSendspinIdMap.entries) {
|
||||||
|
if (entry.value == player.syncedTo) {
|
||||||
|
syncTarget = _availablePlayers.where((p) => p.playerId == entry.key).firstOrNull;
|
||||||
|
if (syncTarget != null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncTarget == null) return false;
|
||||||
|
|
||||||
|
// If synced to a group player, it's part of a pre-configured group
|
||||||
|
if (syncTarget.provider == 'player_group') return false;
|
||||||
|
|
||||||
|
// Synced to a regular player - this is a manual sync child
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Player is a leader with group members
|
||||||
|
if (player.groupMembers != null && player.groupMembers!.length > 1) {
|
||||||
|
// Key distinction: In a MANUAL sync, the leader's own ID is in groupMembers
|
||||||
|
// In a PRE-CONFIGURED group (UGP), the group player's ID is NOT in groupMembers
|
||||||
|
// (the members are the child players, not including the group itself)
|
||||||
|
final isInOwnGroup = player.groupMembers!.contains(player.playerId);
|
||||||
|
if (!isInOwnGroup) {
|
||||||
|
// This is a pre-configured group player (like "All Speakers")
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Leader's ID is in groupMembers = manual sync
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Track? get currentTrack => _currentTrack;
|
Track? get currentTrack => _currentTrack;
|
||||||
|
|
||||||
/// Whether we have cached players available (for instant UI display on app resume)
|
/// Whether we have cached players available (for instant UI display on app resume)
|
||||||
@@ -174,6 +239,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Get cached track for a player (used for smooth swipe transitions)
|
/// Get cached track for a player (used for smooth swipe transitions)
|
||||||
/// For grouped child players, returns the leader's track
|
/// For grouped child players, returns the leader's track
|
||||||
|
/// Also checks translated Cast<->Sendspin IDs (both from map and computed dynamically)
|
||||||
Track? getCachedTrackForPlayer(String playerId) {
|
Track? getCachedTrackForPlayer(String playerId) {
|
||||||
// If player is a group child, get the leader's track instead
|
// If player is a group child, get the leader's track instead
|
||||||
final player = _availablePlayers.firstWhere(
|
final player = _availablePlayers.firstWhere(
|
||||||
@@ -191,7 +257,72 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
? player.syncedTo!
|
? player.syncedTo!
|
||||||
: playerId;
|
: playerId;
|
||||||
|
|
||||||
return _cacheService.getCachedTrackForPlayer(effectivePlayerId);
|
// Try direct lookup first
|
||||||
|
var track = _cacheService.getCachedTrackForPlayer(effectivePlayerId);
|
||||||
|
|
||||||
|
// If not found, try translated Cast<->Sendspin ID
|
||||||
|
if (track == null) {
|
||||||
|
// Check Cast -> Sendspin (from map)
|
||||||
|
final sendspinId = _castToSendspinIdMap[effectivePlayerId];
|
||||||
|
if (sendspinId != null) {
|
||||||
|
track = _cacheService.getCachedTrackForPlayer(sendspinId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Sendspin -> Cast (reverse lookup from map)
|
||||||
|
if (track == null) {
|
||||||
|
for (final entry in _castToSendspinIdMap.entries) {
|
||||||
|
if (entry.value == effectivePlayerId) {
|
||||||
|
track = _cacheService.getCachedTrackForPlayer(entry.key);
|
||||||
|
if (track != null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic ID computation for chromecast players
|
||||||
|
// This handles cases where the map doesn't have the entry yet
|
||||||
|
if (track == null) {
|
||||||
|
// If effectivePlayerId looks like a Sendspin ID (cast-{8chars}), compute Cast ID
|
||||||
|
if (effectivePlayerId.startsWith('cast-') && effectivePlayerId.length >= 13) {
|
||||||
|
// Sendspin ID: cast-7df484e3 -> need to find Cast ID that starts with 7df484e3
|
||||||
|
final prefix = effectivePlayerId.substring(5); // Remove "cast-"
|
||||||
|
// Search through available players for a chromecast player with matching UUID prefix
|
||||||
|
for (final p in _availablePlayers) {
|
||||||
|
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
|
||||||
|
track = _cacheService.getCachedTrackForPlayer(p.playerId);
|
||||||
|
if (track != null) {
|
||||||
|
_logger.log('🔍 Found track via computed Cast ID: ${p.playerId}');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also try direct cache lookup with the prefix as partial ID
|
||||||
|
if (track == null) {
|
||||||
|
// Try common UUID patterns - the cache might have the full Cast UUID
|
||||||
|
final possibleCastIds = _cacheService.getAllCachedPlayerIds()
|
||||||
|
.where((id) => id.startsWith(prefix))
|
||||||
|
.toList();
|
||||||
|
for (final castId in possibleCastIds) {
|
||||||
|
track = _cacheService.getCachedTrackForPlayer(castId);
|
||||||
|
if (track != null) {
|
||||||
|
_logger.log('🔍 Found track via cache scan for prefix $prefix: $castId');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If effectivePlayerId looks like a Cast UUID, compute Sendspin ID
|
||||||
|
if (track == null && effectivePlayerId.length >= 8 && effectivePlayerId.contains('-')) {
|
||||||
|
final computedSendspinId = 'cast-${effectivePlayerId.substring(0, 8)}';
|
||||||
|
track = _cacheService.getCachedTrackForPlayer(computedSendspinId);
|
||||||
|
if (track != null) {
|
||||||
|
_logger.log('🔍 Found track via computed Sendspin ID: $computedSendspinId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return track;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get artwork URL for a player from cache
|
/// Get artwork URL for a player from cache
|
||||||
@@ -443,7 +574,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||||
|
|
||||||
_api!.connectionState.listen(
|
_connectionStateSubscription?.cancel();
|
||||||
|
_connectionStateSubscription = _api!.connectionState.listen(
|
||||||
(state) async {
|
(state) async {
|
||||||
_connectionState = state;
|
_connectionState = state;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1587,8 +1719,16 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final playerId = event['player_id'] as String?;
|
final playerId = event['player_id'] as String?;
|
||||||
if (playerId == null) return;
|
if (playerId == null) return;
|
||||||
|
|
||||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
// Check if this event is for the selected player - also check translated Cast<->Sendspin IDs
|
||||||
_updatePlayerState();
|
// Events may come with Cast ID but selected player uses Sendspin ID (or vice versa)
|
||||||
|
if (_selectedPlayer != null) {
|
||||||
|
final selectedId = _selectedPlayer!.playerId;
|
||||||
|
final isMatch = playerId == selectedId ||
|
||||||
|
_castToSendspinIdMap[playerId] == selectedId ||
|
||||||
|
_castToSendspinIdMap[selectedId] == playerId;
|
||||||
|
if (isMatch) {
|
||||||
|
_updatePlayerState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentMedia = event['current_media'] as Map<String, dynamic>?;
|
final currentMedia = event['current_media'] as Map<String, dynamic>?;
|
||||||
@@ -1703,6 +1843,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (!keepExisting) {
|
if (!keepExisting) {
|
||||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||||
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
|
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
|
||||||
|
|
||||||
|
// Dual-cache for Cast<->Sendspin players so track is findable by either ID
|
||||||
|
final sendspinId = _castToSendspinIdMap[playerId];
|
||||||
|
if (sendspinId != null) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(sendspinId, trackFromEvent);
|
||||||
|
_logger.log('📋 Also cached under Sendspin ID: $sendspinId');
|
||||||
|
} else if (playerId.length >= 8 && playerId.contains('-')) {
|
||||||
|
// Compute Sendspin ID for chromecast players not yet in map
|
||||||
|
final computedSendspinId = 'cast-${playerId.substring(0, 8)}';
|
||||||
|
_cacheService.setCachedTrackForPlayer(computedSendspinId, trackFromEvent);
|
||||||
|
_logger.log('📋 Also cached under computed Sendspin ID: $computedSendspinId');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
|
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
|
||||||
}
|
}
|
||||||
@@ -2204,6 +2356,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load persisted Cast-to-Sendspin mappings from database
|
||||||
|
// This ensures we remember mappings even when Sendspin players are unavailable
|
||||||
|
if (_castToSendspinIdMap.isEmpty) {
|
||||||
|
try {
|
||||||
|
final persistedMappings = await DatabaseService.instance.getAllCastToSendspinMappings();
|
||||||
|
_castToSendspinIdMap.addAll(persistedMappings);
|
||||||
|
if (persistedMappings.isNotEmpty) {
|
||||||
|
_logger.log('🔗 Loaded ${persistedMappings.length} Cast->Sendspin mappings from database');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to load Cast->Sendspin mappings: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final allPlayers = await getPlayers();
|
final allPlayers = await getPlayers();
|
||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
|
||||||
@@ -2258,9 +2424,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// - When ungrouped: show original Cast, hide Sendspin version
|
// - When ungrouped: show original Cast, hide Sendspin version
|
||||||
// This gives power control and proper queue behavior when not syncing
|
// This gives power control and proper queue behavior when not syncing
|
||||||
final sendspinSuffix = ' (Sendspin)';
|
final sendspinSuffix = ' (Sendspin)';
|
||||||
final sendspinPlayers = _availablePlayers
|
|
||||||
.where((p) => p.name.endsWith(sendspinSuffix))
|
// Detect Sendspin players by:
|
||||||
.toList();
|
// 1. Name ends with " (Sendspin)" (e.g., "Kitchen speaker (Sendspin)")
|
||||||
|
// 2. ID starts with "cast-" and name equals ID (e.g., ID="cast-7df484e3", name="cast-7df484e3")
|
||||||
|
// This handles Cast devices where MA registers Sendspin player with raw ID as name
|
||||||
|
bool isSendspinPlayer(Player p) {
|
||||||
|
if (p.name.endsWith(sendspinSuffix)) return true;
|
||||||
|
// Check for Sendspin players named with their raw ID (cast-{uuid-prefix})
|
||||||
|
if (p.playerId.startsWith('cast-') && p.name == p.playerId) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final sendspinPlayers = _availablePlayers.where(isSendspinPlayer).toList();
|
||||||
|
|
||||||
// NOTE: Don't clear _castToSendspinIdMap - we want to remember mappings
|
// NOTE: Don't clear _castToSendspinIdMap - we want to remember mappings
|
||||||
// even when the Sendspin player is temporarily unavailable (e.g., device off)
|
// even when the Sendspin player is temporarily unavailable (e.g., device off)
|
||||||
@@ -2270,18 +2446,47 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Build maps for Sendspin players and their grouped status
|
// Build maps for Sendspin players and their grouped status
|
||||||
final sendspinByBaseName = <String, Player>{};
|
final sendspinByBaseName = <String, Player>{};
|
||||||
final groupedSendspinBaseNames = <String>{};
|
final groupedSendspinBaseNames = <String>{};
|
||||||
|
// Track Sendspin players that have raw ID as name (need renaming)
|
||||||
|
final rawIdSendspinPlayers = <String, Player>{};
|
||||||
|
|
||||||
for (final player in sendspinPlayers) {
|
for (final player in sendspinPlayers) {
|
||||||
final baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
|
String baseName;
|
||||||
|
Player? regularCastPlayer;
|
||||||
|
|
||||||
|
if (player.name.endsWith(sendspinSuffix)) {
|
||||||
|
// Standard "(Sendspin)" suffix naming
|
||||||
|
baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
|
||||||
|
regularCastPlayer = _availablePlayers.where(
|
||||||
|
(p) => p.name == baseName && !isSendspinPlayer(p)
|
||||||
|
).firstOrNull;
|
||||||
|
} else if (player.playerId.startsWith('cast-') && player.name == player.playerId) {
|
||||||
|
// Raw ID naming (e.g., "cast-7df484e3") - find matching Cast player by UUID prefix
|
||||||
|
// Sendspin ID format: cast-{first 8 chars of Cast UUID}
|
||||||
|
// Cast ID format: {uuid} e.g., 7df484e3-d2ee-c897-f746-2dffc29595ff
|
||||||
|
final sendspinPrefix = player.playerId.substring(5); // Remove "cast-" prefix
|
||||||
|
regularCastPlayer = _availablePlayers.where(
|
||||||
|
(p) => p.playerId.startsWith(sendspinPrefix) && !isSendspinPlayer(p)
|
||||||
|
).firstOrNull;
|
||||||
|
baseName = regularCastPlayer?.name ?? player.name;
|
||||||
|
if (regularCastPlayer != null) {
|
||||||
|
rawIdSendspinPlayers[player.playerId] = player;
|
||||||
|
_logger.log('🔍 Found raw-ID Sendspin player: ${player.playerId} matches Cast player "${regularCastPlayer.name}"');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
sendspinByBaseName[baseName] = player;
|
sendspinByBaseName[baseName] = player;
|
||||||
|
|
||||||
// Find the corresponding regular Cast player and store the ID mapping
|
// Store the ID mapping and persist to database
|
||||||
final regularCastPlayer = _availablePlayers.where(
|
|
||||||
(p) => p.name == baseName && !p.name.endsWith(sendspinSuffix)
|
|
||||||
).firstOrNull;
|
|
||||||
if (regularCastPlayer != null) {
|
if (regularCastPlayer != null) {
|
||||||
_castToSendspinIdMap[regularCastPlayer.playerId] = player.playerId;
|
_castToSendspinIdMap[regularCastPlayer.playerId] = player.playerId;
|
||||||
_logger.log('🔗 Mapped Cast ID ${regularCastPlayer.playerId} -> Sendspin ID ${player.playerId}');
|
_logger.log('🔗 Mapped Cast ID ${regularCastPlayer.playerId} -> Sendspin ID ${player.playerId}');
|
||||||
|
// Persist mapping so it survives when Sendspin player is unavailable
|
||||||
|
DatabaseService.instance.saveCastToSendspinMapping(
|
||||||
|
regularCastPlayer.playerId,
|
||||||
|
player.playerId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.isGrouped) {
|
if (player.isGrouped) {
|
||||||
@@ -2294,10 +2499,23 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Filter players based on grouped status
|
// Filter players based on grouped status
|
||||||
_availablePlayers = _availablePlayers.where((player) {
|
_availablePlayers = _availablePlayers.where((player) {
|
||||||
final isSendspin = player.name.endsWith(sendspinSuffix);
|
final isSendspin = isSendspinPlayer(player);
|
||||||
|
|
||||||
if (isSendspin) {
|
if (isSendspin) {
|
||||||
final baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
|
// Get base name for this Sendspin player
|
||||||
|
String baseName;
|
||||||
|
if (player.name.endsWith(sendspinSuffix)) {
|
||||||
|
baseName = player.name.substring(0, player.name.length - sendspinSuffix.length);
|
||||||
|
} else if (rawIdSendspinPlayers.containsKey(player.playerId)) {
|
||||||
|
// For raw ID players, find the base name from our earlier mapping
|
||||||
|
baseName = sendspinByBaseName.entries
|
||||||
|
.firstWhere((e) => e.value.playerId == player.playerId,
|
||||||
|
orElse: () => MapEntry(player.name, player))
|
||||||
|
.key;
|
||||||
|
} else {
|
||||||
|
baseName = player.name;
|
||||||
|
}
|
||||||
|
|
||||||
// Keep Sendspin only if grouped
|
// Keep Sendspin only if grouped
|
||||||
if (player.isGrouped) {
|
if (player.isGrouped) {
|
||||||
return true;
|
return true;
|
||||||
@@ -2317,13 +2535,24 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Rename remaining Sendspin players to remove the suffix
|
// Rename remaining Sendspin players to remove the suffix or give proper name
|
||||||
_availablePlayers = _availablePlayers.map((player) {
|
_availablePlayers = _availablePlayers.map((player) {
|
||||||
if (player.name.endsWith(sendspinSuffix)) {
|
if (player.name.endsWith(sendspinSuffix)) {
|
||||||
final cleanName = player.name.substring(0, player.name.length - sendspinSuffix.length);
|
final cleanName = player.name.substring(0, player.name.length - sendspinSuffix.length);
|
||||||
_logger.log('✨ Renaming "${player.name}" to "$cleanName"');
|
_logger.log('✨ Renaming "${player.name}" to "$cleanName"');
|
||||||
return player.copyWith(name: cleanName);
|
return player.copyWith(name: cleanName);
|
||||||
}
|
}
|
||||||
|
// Rename raw ID Sendspin players to their proper name
|
||||||
|
if (rawIdSendspinPlayers.containsKey(player.playerId)) {
|
||||||
|
final properName = sendspinByBaseName.entries
|
||||||
|
.firstWhere((e) => e.value.playerId == player.playerId,
|
||||||
|
orElse: () => MapEntry(player.name, player))
|
||||||
|
.key;
|
||||||
|
if (properName != player.name) {
|
||||||
|
_logger.log('✨ Renaming raw ID Sendspin "${player.name}" to "$properName"');
|
||||||
|
return player.copyWith(name: properName);
|
||||||
|
}
|
||||||
|
}
|
||||||
return player;
|
return player;
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -2362,16 +2591,34 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// But allow switching to a playing player when preferLocalPlayer is OFF
|
// But allow switching to a playing player when preferLocalPlayer is OFF
|
||||||
// On coldStart, skip this block and apply full priority logic (playing > local > last selected)
|
// On coldStart, skip this block and apply full priority logic (playing > local > last selected)
|
||||||
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
|
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
|
||||||
|
// Check if selected player is still available - also check translated Cast/Sendspin IDs
|
||||||
|
// When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection
|
||||||
|
final selectedId = _selectedPlayer!.playerId;
|
||||||
|
final translatedId = _castToSendspinIdMap[selectedId];
|
||||||
|
String? reverseTranslatedId;
|
||||||
|
for (final entry in _castToSendspinIdMap.entries) {
|
||||||
|
if (entry.value == selectedId) {
|
||||||
|
reverseTranslatedId = entry.key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final stillAvailable = _availablePlayers.any(
|
final stillAvailable = _availablePlayers.any(
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
(p) => p.available && (p.playerId == selectedId ||
|
||||||
|
(translatedId != null && p.playerId == translatedId) ||
|
||||||
|
(reverseTranslatedId != null && p.playerId == reverseTranslatedId)),
|
||||||
);
|
);
|
||||||
if (stillAvailable) {
|
if (stillAvailable) {
|
||||||
|
// Find the actual player in the list (might be Cast or Sendspin version)
|
||||||
|
final currentPlayer = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == selectedId ||
|
||||||
|
(translatedId != null && p.playerId == translatedId) ||
|
||||||
|
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
|
||||||
|
);
|
||||||
|
|
||||||
// If preferLocalPlayer is OFF, check if we should switch to a playing player
|
// If preferLocalPlayer is OFF, check if we should switch to a playing player
|
||||||
if (!preferLocalPlayer) {
|
if (!preferLocalPlayer) {
|
||||||
final currentPlayerState = _availablePlayers
|
final currentIsPlaying = currentPlayer.state == 'playing';
|
||||||
.firstWhere((p) => p.playerId == _selectedPlayer!.playerId)
|
|
||||||
.state;
|
|
||||||
final currentIsPlaying = currentPlayerState == 'playing';
|
|
||||||
// Exclude external sources - they're not playing MA content
|
// Exclude external sources - they're not playing MA content
|
||||||
final playingPlayers = _availablePlayers.where(
|
final playingPlayers = _availablePlayers.where(
|
||||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||||
@@ -2386,9 +2633,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Keep current selection if no switch happened
|
// Keep current selection if no switch happened
|
||||||
if (playerToSelect == null) {
|
if (playerToSelect == null) {
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
playerToSelect = currentPlayer;
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
if (currentPlayer.playerId != selectedId) {
|
||||||
);
|
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2437,12 +2685,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Priority 3: Last manually selected player
|
// Priority 3: Last manually selected player
|
||||||
if (playerToSelect == null && lastSelectedPlayerId != null) {
|
if (playerToSelect == null && lastSelectedPlayerId != null) {
|
||||||
try {
|
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
||||||
(p) => p.playerId == lastSelectedPlayerId && p.available,
|
orElse: () => null,
|
||||||
);
|
);
|
||||||
|
if (playerToSelect != null) {
|
||||||
_logger.log('🔄 Auto-selected last used player: ${playerToSelect?.name}');
|
_logger.log('🔄 Auto-selected last used player: ${playerToSelect?.name}');
|
||||||
} catch (e) {}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 4: First available player
|
// Priority 4: First available player
|
||||||
@@ -2466,7 +2715,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void selectPlayer(Player player, {bool skipNotify = false}) async {
|
void selectPlayer(Player player, {bool skipNotify = false}) async {
|
||||||
_selectedPlayer = player;
|
// Reentrancy guard - prevent concurrent selection which can cause race conditions
|
||||||
|
if (_selectPlayerInProgress) {
|
||||||
|
_logger.log('⚠️ selectPlayer already in progress, skipping for ${player.name}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_selectPlayerInProgress = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_selectedPlayer = player;
|
||||||
|
|
||||||
// Cache for instant display on app resume
|
// Cache for instant display on app resume
|
||||||
_cacheService.setCachedSelectedPlayer(player);
|
_cacheService.setCachedSelectedPlayer(player);
|
||||||
@@ -2594,6 +2851,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (!skipNotify) {
|
if (!skipNotify) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
_selectPlayerInProgress = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle to the next active player (for notification switch button)
|
/// Cycle to the next active player (for notification switch button)
|
||||||
@@ -2815,6 +3075,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
||||||
_logger.log('🔍 Preload ${player.name}: CACHED track "${track.name}"');
|
_logger.log('🔍 Preload ${player.name}: CACHED track "${track.name}"');
|
||||||
|
|
||||||
|
// Dual-cache for Cast<->Sendspin players
|
||||||
|
final sendspinId = _castToSendspinIdMap[player.playerId];
|
||||||
|
if (sendspinId != null) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(sendspinId, track);
|
||||||
|
} else if (player.provider == 'chromecast' && player.playerId.length >= 8) {
|
||||||
|
final computedSendspinId = 'cast-${player.playerId.substring(0, 8)}';
|
||||||
|
_cacheService.setCachedTrackForPlayer(computedSendspinId, track);
|
||||||
|
}
|
||||||
|
|
||||||
// Also precache the image so it's ready for swipe preview
|
// Also precache the image so it's ready for swipe preview
|
||||||
final imageUrl = getImageUrl(track, size: 512);
|
final imageUrl = getImageUrl(track, size: 512);
|
||||||
if (imageUrl != null) {
|
if (imageUrl != null) {
|
||||||
@@ -2914,12 +3183,53 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
bool stateChanged = false;
|
bool stateChanged = false;
|
||||||
|
|
||||||
|
// Get fresh player data - but look up from _availablePlayers which has
|
||||||
|
// the processed names (Sendspin suffix removed) and correct filtering
|
||||||
|
// Also check translated Cast<->Sendspin IDs
|
||||||
|
final selectedId = _selectedPlayer!.playerId;
|
||||||
|
final translatedSendspinId = _castToSendspinIdMap[selectedId];
|
||||||
|
String? translatedCastId;
|
||||||
|
for (final entry in _castToSendspinIdMap.entries) {
|
||||||
|
if (entry.value == selectedId) {
|
||||||
|
translatedCastId = entry.key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to refresh from API first for latest state
|
||||||
final allPlayers = await getPlayers();
|
final allPlayers = await getPlayers();
|
||||||
final updatedPlayer = allPlayers.firstWhere(
|
Player? rawPlayer = allPlayers.firstWhere(
|
||||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
(p) => p.playerId == selectedId ||
|
||||||
|
(translatedSendspinId != null && p.playerId == translatedSendspinId) ||
|
||||||
|
(translatedCastId != null && p.playerId == translatedCastId),
|
||||||
orElse: () => _selectedPlayer!,
|
orElse: () => _selectedPlayer!,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Now get the processed version from _availablePlayers to preserve renamed name
|
||||||
|
// But update with latest state (volume, playing state, etc.) from rawPlayer
|
||||||
|
final processedPlayer = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == selectedId ||
|
||||||
|
(translatedSendspinId != null && p.playerId == translatedSendspinId) ||
|
||||||
|
(translatedCastId != null && p.playerId == translatedCastId),
|
||||||
|
orElse: () => rawPlayer,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the processed player's name but raw player's state
|
||||||
|
final updatedPlayer = processedPlayer.copyWith(
|
||||||
|
state: rawPlayer.state,
|
||||||
|
volumeLevel: rawPlayer.volumeLevel,
|
||||||
|
volumeMuted: rawPlayer.volumeMuted,
|
||||||
|
elapsedTime: rawPlayer.elapsedTime,
|
||||||
|
elapsedTimeLastUpdated: rawPlayer.elapsedTimeLastUpdated,
|
||||||
|
powered: rawPlayer.powered,
|
||||||
|
available: rawPlayer.available,
|
||||||
|
groupMembers: rawPlayer.groupMembers,
|
||||||
|
syncedTo: rawPlayer.syncedTo,
|
||||||
|
currentItemId: rawPlayer.currentItemId,
|
||||||
|
isExternalSource: rawPlayer.isExternalSource,
|
||||||
|
appId: rawPlayer.appId,
|
||||||
|
);
|
||||||
|
|
||||||
_selectedPlayer = updatedPlayer;
|
_selectedPlayer = updatedPlayer;
|
||||||
stateChanged = true;
|
stateChanged = true;
|
||||||
|
|
||||||
@@ -3037,6 +3347,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
// Update cache if queue track has good metadata
|
// Update cache if queue track has good metadata
|
||||||
if (queueHasImage || queueHasProperArtist) {
|
if (queueHasImage || queueHasProperArtist) {
|
||||||
_cacheService.setCachedTrackForPlayer(_selectedPlayer!.playerId, queueTrack);
|
_cacheService.setCachedTrackForPlayer(_selectedPlayer!.playerId, queueTrack);
|
||||||
|
|
||||||
|
// Dual-cache for Cast<->Sendspin players
|
||||||
|
final sendspinId = _castToSendspinIdMap[_selectedPlayer!.playerId];
|
||||||
|
if (sendspinId != null) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(sendspinId, queueTrack);
|
||||||
|
} else if (_selectedPlayer!.provider == 'chromecast' && _selectedPlayer!.playerId.length >= 8) {
|
||||||
|
final computedSendspinId = 'cast-${_selectedPlayer!.playerId.substring(0, 8)}';
|
||||||
|
_cacheService.setCachedTrackForPlayer(computedSendspinId, queueTrack);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stateChanged = true;
|
stateChanged = true;
|
||||||
@@ -3409,6 +3728,31 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
effectivePlayerId = player.syncedTo!;
|
effectivePlayerId = player.syncedTo!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Translate Sendspin ID to Cast UUID for queue fetch
|
||||||
|
// MA stores queues under the Cast UUID, not the Sendspin ID
|
||||||
|
if (effectivePlayerId.startsWith('cast-') && effectivePlayerId.length >= 13) {
|
||||||
|
// Sendspin ID format: cast-7df484e3 -> need Cast UUID starting with 7df484e3
|
||||||
|
final prefix = effectivePlayerId.substring(5); // Remove "cast-"
|
||||||
|
// Reverse lookup in the map
|
||||||
|
for (final entry in _castToSendspinIdMap.entries) {
|
||||||
|
if (entry.value == effectivePlayerId) {
|
||||||
|
_logger.log('🔗 Translated Sendspin ID $effectivePlayerId to Cast UUID ${entry.key} for queue fetch');
|
||||||
|
effectivePlayerId = entry.key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not in map, try to find in available players
|
||||||
|
if (effectivePlayerId.startsWith('cast-')) {
|
||||||
|
for (final p in _availablePlayers) {
|
||||||
|
if (p.provider == 'chromecast' && p.playerId.startsWith(prefix)) {
|
||||||
|
_logger.log('🔗 Found Cast UUID ${p.playerId} for Sendspin ID $effectivePlayerId via player lookup');
|
||||||
|
effectivePlayerId = p.playerId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final queue = await _api?.getQueue(effectivePlayerId);
|
final queue = await _api?.getQueue(effectivePlayerId);
|
||||||
|
|
||||||
// Persist queue to database for instant display on app resume
|
// Persist queue to database for instant display on app resume
|
||||||
@@ -3544,8 +3888,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_logger.log('⏸️ Non-blocking local pause for builtin player');
|
_logger.log('⏸️ Non-blocking local pause for builtin player');
|
||||||
|
|
||||||
// CRITICAL: Don't await these - they can block the UI thread
|
// CRITICAL: Don't await these - they can block the UI thread
|
||||||
// Use unawaited to make them fire-and-forget
|
// Use unawaited to make them fire-and-forget, but log errors
|
||||||
unawaited(_pcmAudioPlayer?.pause() ?? Future.value());
|
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
|
||||||
|
(e) => _logger.log('⚠️ PCM pause error (non-blocking): $e'),
|
||||||
|
));
|
||||||
|
|
||||||
// Don't pause just_audio for Sendspin mode - it's not being used for audio output
|
// Don't pause just_audio for Sendspin mode - it's not being used for audio output
|
||||||
// and calling pause() on it can cause blocking issues
|
// and calling pause() on it can cause blocking issues
|
||||||
@@ -3561,8 +3907,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send command to MA for proper state sync - don't await
|
// Send command to MA for proper state sync - don't await, but log errors
|
||||||
unawaited(_api?.pausePlayer(playerId) ?? Future.value());
|
unawaited((_api?.pausePlayer(playerId) ?? Future.value()).catchError(
|
||||||
|
(e) => _logger.log('⚠️ MA pause command error (non-blocking): $e'),
|
||||||
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Pause player', e);
|
ErrorHandler.logError('Pause player', e);
|
||||||
// Don't rethrow - we want pause to be resilient
|
// Don't rethrow - we want pause to be resilient
|
||||||
@@ -3640,8 +3988,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||||
_logger.log('⏭️ Non-blocking local stop for skip on builtin player');
|
_logger.log('⏭️ Non-blocking local stop for skip on builtin player');
|
||||||
// Stop current audio immediately - fire and forget
|
// Stop current audio immediately - fire and forget, but log errors
|
||||||
unawaited(_pcmAudioPlayer?.pause() ?? Future.value());
|
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
|
||||||
|
(e) => _logger.log('⚠️ PCM pause error on next (non-blocking): $e'),
|
||||||
|
));
|
||||||
// Don't stop just_audio - not used for Sendspin audio output
|
// Don't stop just_audio - not used for Sendspin audio output
|
||||||
}
|
}
|
||||||
await _api?.nextTrack(playerId);
|
await _api?.nextTrack(playerId);
|
||||||
@@ -3657,8 +4007,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected) {
|
||||||
_logger.log('⏮️ Non-blocking local stop for previous on builtin player');
|
_logger.log('⏮️ Non-blocking local stop for previous on builtin player');
|
||||||
// Stop current audio immediately - fire and forget
|
// Stop current audio immediately - fire and forget, but log errors
|
||||||
unawaited(_pcmAudioPlayer?.pause() ?? Future.value());
|
unawaited((_pcmAudioPlayer?.pause() ?? Future.value()).catchError(
|
||||||
|
(e) => _logger.log('⚠️ PCM pause error on previous (non-blocking): $e'),
|
||||||
|
));
|
||||||
// Don't stop just_audio - not used for Sendspin audio output
|
// Don't stop just_audio - not used for Sendspin audio output
|
||||||
}
|
}
|
||||||
await _api?.previousTrack(playerId);
|
await _api?.previousTrack(playerId);
|
||||||
@@ -3699,15 +4051,43 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate Cast player ID to Sendspin ID if available
|
// Translate Cast player IDs to Sendspin IDs for group commands
|
||||||
// This is needed because regular Cast players can't sync with Sendspin players
|
// Cast players don't support group commands - only their Sendspin counterparts do
|
||||||
final actualTargetId = _castToSendspinIdMap[targetPlayerId] ?? targetPlayerId;
|
// We need to translate BOTH target and leader IDs
|
||||||
if (actualTargetId != targetPlayerId) {
|
//
|
||||||
_logger.log('🔗 Translated Cast ID to Sendspin ID: $targetPlayerId -> $actualTargetId');
|
// The mapping comes from _castToSendspinIdMap which contains:
|
||||||
|
// 1. Currently discovered mappings (from available Sendspin players)
|
||||||
|
// 2. Persisted mappings from database (survives when Sendspin player is unavailable)
|
||||||
|
String translateToSendspinId(String playerId, Player? player) {
|
||||||
|
if (_castToSendspinIdMap.containsKey(playerId)) {
|
||||||
|
_logger.log('🔗 Found Sendspin mapping: $playerId -> ${_castToSendspinIdMap[playerId]}');
|
||||||
|
return _castToSendspinIdMap[playerId]!;
|
||||||
|
}
|
||||||
|
// No Sendspin counterpart known - use original ID
|
||||||
|
return playerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('🔗 Calling API syncPlayerToLeader($actualTargetId, ${leaderPlayer.playerId})');
|
// Find target player to check its provider
|
||||||
await _api!.syncPlayerToLeader(actualTargetId, leaderPlayer.playerId);
|
final targetPlayer = _availablePlayers.where((p) => p.playerId == targetPlayerId).firstOrNull;
|
||||||
|
|
||||||
|
// Translate BOTH target AND leader to Sendspin IDs
|
||||||
|
// Only Sendspin players support the SET_MEMBERS feature required for grouping
|
||||||
|
// Cast players will return "Player X does not support group commands"
|
||||||
|
final actualTargetId = translateToSendspinId(targetPlayerId, targetPlayer);
|
||||||
|
String actualLeaderId = leaderPlayer.playerId;
|
||||||
|
|
||||||
|
// If leader is a Cast player, translate to its Sendspin counterpart
|
||||||
|
if (_castToSendspinIdMap.containsKey(leaderPlayer.playerId)) {
|
||||||
|
actualLeaderId = _castToSendspinIdMap[leaderPlayer.playerId]!;
|
||||||
|
_logger.log('🔗 Translated leader Cast ID to Sendspin ID: ${leaderPlayer.playerId} -> $actualLeaderId');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualTargetId != targetPlayerId) {
|
||||||
|
_logger.log('🔗 Translated target Cast ID to Sendspin ID: $targetPlayerId -> $actualTargetId');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🔗 Calling API syncPlayerToLeader($actualTargetId, $actualLeaderId)');
|
||||||
|
await _api!.syncPlayerToLeader(actualTargetId, actualLeaderId);
|
||||||
_logger.log('✅ API sync call completed');
|
_logger.log('✅ API sync call completed');
|
||||||
|
|
||||||
// Refresh players to get updated group state
|
// Refresh players to get updated group state
|
||||||
@@ -3725,7 +4105,51 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
Future<void> unsyncPlayer(String playerId) async {
|
Future<void> unsyncPlayer(String playerId) async {
|
||||||
try {
|
try {
|
||||||
_logger.log('🔓 Unsyncing player: $playerId');
|
_logger.log('🔓 Unsyncing player: $playerId');
|
||||||
await _api?.unsyncPlayer(playerId);
|
|
||||||
|
// Find the player to check if it's a child
|
||||||
|
final player = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == playerId,
|
||||||
|
orElse: () => Player(
|
||||||
|
playerId: playerId,
|
||||||
|
name: '',
|
||||||
|
available: false,
|
||||||
|
powered: false,
|
||||||
|
state: 'idle',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine the effective player ID to unsync
|
||||||
|
// For children, we unsync the child directly (not the leader!)
|
||||||
|
// Unsyncing the leader would dissolve the entire group
|
||||||
|
String effectivePlayerId = playerId;
|
||||||
|
String? leaderId = player.syncedTo;
|
||||||
|
|
||||||
|
if (leaderId != null) {
|
||||||
|
_logger.log('🔓 Player is a child synced to $leaderId, unsyncing child directly');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to unsync the player directly
|
||||||
|
try {
|
||||||
|
await _api?.unsyncPlayer(effectivePlayerId);
|
||||||
|
} catch (e) {
|
||||||
|
// Some players (like pure Sendspin CLI players) don't support set_members
|
||||||
|
// In this case, we need to unsync via the leader instead
|
||||||
|
if (e.toString().contains('set_members') && leaderId != null) {
|
||||||
|
_logger.log('🔓 Child unsync failed (set_members not supported), unsyncing via leader');
|
||||||
|
|
||||||
|
// Try to find the Sendspin ID for the leader if it's a Cast UUID
|
||||||
|
String leaderToUnsync = leaderId;
|
||||||
|
if (_castToSendspinIdMap.containsKey(leaderId)) {
|
||||||
|
leaderToUnsync = _castToSendspinIdMap[leaderId]!;
|
||||||
|
_logger.log('🔓 Translated leader to Sendspin ID: $leaderToUnsync');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _api?.unsyncPlayer(leaderToUnsync);
|
||||||
|
_logger.log('⚠️ Dissolved entire group (pure Sendspin player limitation)');
|
||||||
|
} else {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh players to get updated group state
|
// Refresh players to get updated group state
|
||||||
await refreshPlayers();
|
await refreshPlayers();
|
||||||
@@ -4035,6 +4459,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
_playerStateTimer?.cancel();
|
_playerStateTimer?.cancel();
|
||||||
_notificationPositionTimer?.cancel();
|
_notificationPositionTimer?.cancel();
|
||||||
_localPlayerStateReportTimer?.cancel();
|
_localPlayerStateReportTimer?.cancel();
|
||||||
|
_connectionStateSubscription?.cancel();
|
||||||
_localPlayerEventSubscription?.cancel();
|
_localPlayerEventSubscription?.cancel();
|
||||||
_playerUpdatedEventSubscription?.cancel();
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
_playerAddedEventSubscription?.cancel();
|
_playerAddedEventSubscription?.cancel();
|
||||||
|
|||||||
@@ -559,29 +559,28 @@ class PlayerProvider with ChangeNotifier {
|
|||||||
if (playerToSelect == null) {
|
if (playerToSelect == null) {
|
||||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||||
|
|
||||||
|
// Priority 1: Last selected player
|
||||||
if (lastSelectedPlayerId != null) {
|
if (lastSelectedPlayerId != null) {
|
||||||
try {
|
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
||||||
(p) => p.playerId == lastSelectedPlayerId && p.available,
|
orElse: () => null,
|
||||||
);
|
);
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 2: Built-in (local) player
|
||||||
if (playerToSelect == null && builtinPlayerId != null) {
|
if (playerToSelect == null && builtinPlayerId != null) {
|
||||||
try {
|
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
(p) => p!.playerId == builtinPlayerId && p.available,
|
||||||
(p) => p.playerId == builtinPlayerId && p.available,
|
orElse: () => null,
|
||||||
);
|
);
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try playing player (exclude external sources - not playing MA content)
|
// Priority 3: Currently playing player (exclude external sources - not playing MA content)
|
||||||
if (playerToSelect == null) {
|
if (playerToSelect == null) {
|
||||||
try {
|
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||||
playerToSelect = _availablePlayers.firstWhere(
|
(p) => p!.state == 'playing' && p.available && !p.isExternalSource,
|
||||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
orElse: () => null,
|
||||||
);
|
);
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerToSelect == null) {
|
if (playerToSelect == null) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../services/metadata_service.dart';
|
|||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../services/recently_played_service.dart';
|
import '../services/recently_played_service.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../widgets/player_picker_sheet.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
|
||||||
@@ -912,14 +913,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
|
||||||
S.of(context)!.tracks,
|
|
||||||
style: textTheme.titleLarge?.copyWith(
|
|
||||||
color: colorScheme.onBackground,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1095,109 +1088,62 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
|
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
|
||||||
|
|
||||||
_showPlayOnSheet(
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
context,
|
|
||||||
players,
|
showPlayerPickerSheet(
|
||||||
onPlayerSelected: (player) {
|
context: context,
|
||||||
|
title: S.of(context)!.playOn,
|
||||||
|
players: maProvider.availablePlayers,
|
||||||
|
selectedPlayer: maProvider.selectedPlayer,
|
||||||
|
onPlayerSelected: (player) async {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
maProvider.playTracks(
|
await maProvider.playTracks(
|
||||||
player.playerId,
|
player.playerId,
|
||||||
_tracks,
|
_tracks,
|
||||||
startIndex: startIndex,
|
startIndex: startIndex,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
).whenComplete(() {
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
|
||||||
final track = _tracks[trackIndex];
|
final track = _tracks[trackIndex];
|
||||||
|
|
||||||
_showPlayOnSheet(
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
context,
|
|
||||||
players,
|
showPlayerPickerSheet(
|
||||||
onPlayerSelected: (player) {
|
context: context,
|
||||||
|
title: S.of(context)!.playOn,
|
||||||
|
players: maProvider.availablePlayers,
|
||||||
|
selectedPlayer: maProvider.selectedPlayer,
|
||||||
|
onPlayerSelected: (player) async {
|
||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
maProvider.playRadio(player.playerId, track);
|
await maProvider.playRadio(player.playerId, track);
|
||||||
},
|
},
|
||||||
);
|
).whenComplete(() {
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPlayOnMenu(BuildContext context) {
|
void _showPlayOnMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
|
||||||
|
|
||||||
_showPlayOnSheet(
|
|
||||||
context,
|
|
||||||
players,
|
|
||||||
onPlayerSelected: (player) {
|
|
||||||
maProvider.selectPlayer(player);
|
|
||||||
maProvider.playTracks(player.playerId, _tracks);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared "Play on..." bottom sheet that sizes to content
|
|
||||||
void _showPlayOnSheet(
|
|
||||||
BuildContext context,
|
|
||||||
List players, {
|
|
||||||
required void Function(dynamic player) onPlayerSelected,
|
|
||||||
}) {
|
|
||||||
// Slide mini player down out of the way
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
|
||||||
showModalBottomSheet(
|
showPlayerPickerSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
title: S.of(context)!.playOn,
|
||||||
builder: (context) => Container(
|
players: maProvider.availablePlayers,
|
||||||
decoration: BoxDecoration(
|
selectedPlayer: maProvider.selectedPlayer,
|
||||||
color: Theme.of(context).colorScheme.surface,
|
onPlayerSelected: (player) async {
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
maProvider.selectPlayer(player);
|
||||||
),
|
await maProvider.playTracks(player.playerId, _tracks);
|
||||||
child: Column(
|
},
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
S.of(context)!.playOn,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (players.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Text(S.of(context)!.noPlayersAvailable),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Flexible(
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: players.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final player = players[index];
|
|
||||||
return ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
Icons.speaker,
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
title: Text(player.name),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
onPlayerSelected(player);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).whenComplete(() {
|
).whenComplete(() {
|
||||||
// Slide mini player back up when sheet is dismissed
|
|
||||||
GlobalPlayerOverlay.showPlayer();
|
GlobalPlayerOverlay.showPlayer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../widgets/player_picker_sheet.dart';
|
||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
@@ -209,65 +210,23 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
|||||||
|
|
||||||
void _showPlayOnMenu(BuildContext context) {
|
void _showPlayOnMenu(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final players = maProvider.availablePlayers;
|
|
||||||
|
|
||||||
// Slide mini player down out of the way
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
|
||||||
showModalBottomSheet(
|
showPlayerPickerSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
title: S.of(context)!.playOn,
|
||||||
builder: (context) => Container(
|
players: maProvider.availablePlayers,
|
||||||
decoration: BoxDecoration(
|
selectedPlayer: maProvider.selectedPlayer,
|
||||||
color: Theme.of(context).colorScheme.surface,
|
onPlayerSelected: (player) async {
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
maProvider.selectPlayer(player);
|
||||||
),
|
maProvider.setCurrentAudiobook(_audiobook);
|
||||||
child: Column(
|
await maProvider.api?.playAudiobook(
|
||||||
mainAxisSize: MainAxisSize.min,
|
player.playerId,
|
||||||
children: [
|
widget.audiobook,
|
||||||
const SizedBox(height: 16),
|
);
|
||||||
Text(
|
},
|
||||||
S.of(context)!.playOn,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (players.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Text(S.of(context)!.noPlayersAvailable),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Flexible(
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: players.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final player = players[index];
|
|
||||||
return ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
Icons.speaker,
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
title: Text(player.name),
|
|
||||||
onTap: () async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
maProvider.selectPlayer(player);
|
|
||||||
maProvider.setCurrentAudiobook(_audiobook);
|
|
||||||
await maProvider.api?.playAudiobook(
|
|
||||||
player.playerId,
|
|
||||||
widget.audiobook,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).whenComplete(() {
|
).whenComplete(() {
|
||||||
// Slide mini player back up when sheet is dismissed
|
|
||||||
GlobalPlayerOverlay.showPlayer();
|
GlobalPlayerOverlay.showPlayer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
|||||||
_buildAudiobookSliver(maProvider),
|
_buildAudiobookSliver(maProvider),
|
||||||
|
|
||||||
// Bottom padding for mini player
|
// Bottom padding for mini player
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:audio_session/audio_session.dart';
|
import 'package:audio_session/audio_session.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
@@ -11,6 +12,12 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
final AuthManager authManager;
|
final AuthManager authManager;
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
|
// Stream subscriptions for proper cleanup
|
||||||
|
StreamSubscription? _interruptionSubscription;
|
||||||
|
StreamSubscription? _becomingNoisySubscription;
|
||||||
|
StreamSubscription? _playbackEventSubscription;
|
||||||
|
StreamSubscription? _currentIndexSubscription;
|
||||||
|
|
||||||
// Track current metadata separately from what's in the notification
|
// Track current metadata separately from what's in the notification
|
||||||
// This allows us to update the notification when metadata arrives late
|
// This allows us to update the notification when metadata arrives late
|
||||||
MediaItem? _currentMediaItem;
|
MediaItem? _currentMediaItem;
|
||||||
@@ -42,7 +49,7 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
await session.configure(const AudioSessionConfiguration.music());
|
await session.configure(const AudioSessionConfiguration.music());
|
||||||
|
|
||||||
// Handle audio interruptions
|
// Handle audio interruptions
|
||||||
session.interruptionEventStream.listen((event) {
|
_interruptionSubscription = session.interruptionEventStream.listen((event) {
|
||||||
if (event.begin) {
|
if (event.begin) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case AudioInterruptionType.duck:
|
case AudioInterruptionType.duck:
|
||||||
@@ -68,15 +75,15 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle becoming noisy (headphones unplugged)
|
// Handle becoming noisy (headphones unplugged)
|
||||||
session.becomingNoisyEventStream.listen((_) {
|
_becomingNoisySubscription = session.becomingNoisyEventStream.listen((_) {
|
||||||
pause();
|
pause();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast playback state changes
|
// Broadcast playback state changes
|
||||||
_player.playbackEventStream.listen(_broadcastState);
|
_playbackEventSubscription = _player.playbackEventStream.listen(_broadcastState);
|
||||||
|
|
||||||
// Broadcast current media item changes
|
// Broadcast current media item changes
|
||||||
_player.currentIndexStream.listen((_) {
|
_currentIndexSubscription = _player.currentIndexStream.listen((_) {
|
||||||
if (_currentMediaItem != null) {
|
if (_currentMediaItem != null) {
|
||||||
mediaItem.add(_currentMediaItem);
|
mediaItem.add(_currentMediaItem);
|
||||||
}
|
}
|
||||||
@@ -293,4 +300,13 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
Stream<Duration?> get durationStream => _player.durationStream;
|
Stream<Duration?> get durationStream => _player.durationStream;
|
||||||
|
|
||||||
MediaItem? get currentMediaItem => _currentMediaItem;
|
MediaItem? get currentMediaItem => _currentMediaItem;
|
||||||
|
|
||||||
|
/// Dispose of resources and cancel all subscriptions
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await _interruptionSubscription?.cancel();
|
||||||
|
await _becomingNoisySubscription?.cancel();
|
||||||
|
await _playbackEventSubscription?.cancel();
|
||||||
|
await _currentIndexSubscription?.cancel();
|
||||||
|
await _player.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ class CacheService {
|
|||||||
final DebugLogger _logger = DebugLogger();
|
final DebugLogger _logger = DebugLogger();
|
||||||
bool _homeRowsLoaded = false;
|
bool _homeRowsLoaded = false;
|
||||||
|
|
||||||
|
// Cache size limits to prevent unbounded memory growth
|
||||||
|
static const int _maxDetailCacheSize = 50; // Max albums/playlists/artists cached
|
||||||
|
static const int _maxSearchCacheSize = 20; // Max search queries cached
|
||||||
|
static const int _maxPlayerTrackCacheSize = 30; // Max player track associations
|
||||||
|
|
||||||
// Home screen row caching
|
// Home screen row caching
|
||||||
List<Album>? _cachedRecentAlbums;
|
List<Album>? _cachedRecentAlbums;
|
||||||
List<Artist>? _cachedDiscoverArtists;
|
List<Artist>? _cachedDiscoverArtists;
|
||||||
@@ -38,6 +43,7 @@ class CacheService {
|
|||||||
|
|
||||||
// Player track cache (for smooth swipe transitions)
|
// Player track cache (for smooth swipe transitions)
|
||||||
final Map<String, Track?> _playerTrackCache = {};
|
final Map<String, Track?> _playerTrackCache = {};
|
||||||
|
final Map<String, DateTime> _playerTrackCacheTime = {};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HOME SCREEN ROW CACHING
|
// HOME SCREEN ROW CACHING
|
||||||
@@ -135,6 +141,7 @@ class CacheService {
|
|||||||
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
|
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
|
||||||
_albumTracksCache[cacheKey] = tracks;
|
_albumTracksCache[cacheKey] = tracks;
|
||||||
_albumTracksCacheTime[cacheKey] = DateTime.now();
|
_albumTracksCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
|
||||||
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
|
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +168,7 @@ class CacheService {
|
|||||||
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
|
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
|
||||||
_playlistTracksCache[cacheKey] = tracks;
|
_playlistTracksCache[cacheKey] = tracks;
|
||||||
_playlistTracksCacheTime[cacheKey] = DateTime.now();
|
_playlistTracksCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
|
||||||
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
|
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +195,7 @@ class CacheService {
|
|||||||
void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
|
void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
|
||||||
_artistAlbumsCache[cacheKey] = albums;
|
_artistAlbumsCache[cacheKey] = albums;
|
||||||
_artistAlbumsCacheTime[cacheKey] = DateTime.now();
|
_artistAlbumsCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_evictOldestEntries(_artistAlbumsCache, _artistAlbumsCacheTime, _maxDetailCacheSize);
|
||||||
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
|
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +220,7 @@ class CacheService {
|
|||||||
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
|
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
|
||||||
_searchCache[cacheKey] = results;
|
_searchCache[cacheKey] = results;
|
||||||
_searchCacheTime[cacheKey] = DateTime.now();
|
_searchCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_evictOldestEntries(_searchCache, _searchCacheTime, _maxSearchCacheSize);
|
||||||
_logger.log('✅ Cached search results for "$cacheKey"');
|
_logger.log('✅ Cached search results for "$cacheKey"');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,14 +264,20 @@ class CacheService {
|
|||||||
/// Get cached track for a player (used for smooth swipe transitions)
|
/// Get cached track for a player (used for smooth swipe transitions)
|
||||||
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
|
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
|
||||||
|
|
||||||
|
/// Get all player IDs that have cached tracks
|
||||||
|
Iterable<String> getAllCachedPlayerIds() => _playerTrackCache.keys;
|
||||||
|
|
||||||
/// Set cached track for a player
|
/// Set cached track for a player
|
||||||
void setCachedTrackForPlayer(String playerId, Track? track) {
|
void setCachedTrackForPlayer(String playerId, Track? track) {
|
||||||
_playerTrackCache[playerId] = track;
|
_playerTrackCache[playerId] = track;
|
||||||
|
_playerTrackCacheTime[playerId] = DateTime.now();
|
||||||
|
_evictOldestEntries(_playerTrackCache, _playerTrackCacheTime, _maxPlayerTrackCacheSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear cached track for a player (e.g., when external source is active)
|
/// Clear cached track for a player (e.g., when external source is active)
|
||||||
void clearCachedTrackForPlayer(String playerId) {
|
void clearCachedTrackForPlayer(String playerId) {
|
||||||
_playerTrackCache.remove(playerId);
|
_playerTrackCache.remove(playerId);
|
||||||
|
_playerTrackCacheTime.remove(playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -279,6 +295,7 @@ class CacheService {
|
|||||||
_searchCache.clear();
|
_searchCache.clear();
|
||||||
_searchCacheTime.clear();
|
_searchCacheTime.clear();
|
||||||
_playerTrackCache.clear();
|
_playerTrackCache.clear();
|
||||||
|
_playerTrackCacheTime.clear();
|
||||||
_logger.log('🗑️ All detail caches cleared');
|
_logger.log('🗑️ All detail caches cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,4 +381,34 @@ class CacheService {
|
|||||||
}
|
}
|
||||||
}();
|
}();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LRU CACHE EVICTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Evict oldest entries from cache maps to enforce size limit (LRU eviction)
|
||||||
|
/// Uses the timestamp map to determine which entries are oldest
|
||||||
|
void _evictOldestEntries<K, V>(
|
||||||
|
Map<K, V> cache,
|
||||||
|
Map<K, DateTime> cacheTime,
|
||||||
|
int maxSize,
|
||||||
|
) {
|
||||||
|
if (cache.length <= maxSize) return;
|
||||||
|
|
||||||
|
// Sort keys by timestamp (oldest first)
|
||||||
|
final sortedKeys = cacheTime.keys.toList()
|
||||||
|
..sort((a, b) => (cacheTime[a] ?? DateTime.now())
|
||||||
|
.compareTo(cacheTime[b] ?? DateTime.now()));
|
||||||
|
|
||||||
|
// Remove oldest entries until we're at maxSize
|
||||||
|
final keysToRemove = sortedKeys.take(cache.length - maxSize);
|
||||||
|
for (final key in keysToRemove) {
|
||||||
|
cache.remove(key);
|
||||||
|
cacheTime.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keysToRemove.isNotEmpty) {
|
||||||
|
_logger.log('🗑️ LRU evicted ${keysToRemove.length} cache entries');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,4 +252,53 @@ class DatabaseService {
|
|||||||
|
|
||||||
/// Clear all search history
|
/// Clear all search history
|
||||||
Future<void> clearSearchHistory() => db.clearSearchHistory();
|
Future<void> clearSearchHistory() => db.clearSearchHistory();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Cast-to-Sendspin Mapping Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
static const String _sendspinMappingType = 'cast_sendspin_mapping';
|
||||||
|
|
||||||
|
/// Save a Cast UUID to Sendspin ID mapping
|
||||||
|
Future<void> saveCastToSendspinMapping(String castId, String sendspinId) {
|
||||||
|
_logger.log('💾 Persisting Cast->Sendspin mapping: $castId -> $sendspinId');
|
||||||
|
return db.cacheItem(
|
||||||
|
itemType: _sendspinMappingType,
|
||||||
|
itemId: castId,
|
||||||
|
data: jsonEncode({'sendspinId': sendspinId}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get persisted Sendspin ID for a Cast UUID
|
||||||
|
Future<String?> getSendspinIdForCast(String castId) async {
|
||||||
|
final item = await db.getCachedItem(_sendspinMappingType, castId);
|
||||||
|
if (item == null) return null;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(item.data) as Map<String, dynamic>;
|
||||||
|
return data['sendspinId'] as String?;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error decoding Sendspin mapping: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all persisted Cast-to-Sendspin mappings
|
||||||
|
Future<Map<String, String>> getAllCastToSendspinMappings() async {
|
||||||
|
final items = await db.getCachedItems(_sendspinMappingType);
|
||||||
|
final mappings = <String, String>{};
|
||||||
|
for (final item in items) {
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(item.data) as Map<String, dynamic>;
|
||||||
|
final sendspinId = data['sendspinId'] as String?;
|
||||||
|
if (sendspinId != null) {
|
||||||
|
// The itemId is the Cast UUID
|
||||||
|
mappings[item.itemId] = sendspinId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error decoding Sendspin mapping: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.log('📦 Loaded ${mappings.length} Cast->Sendspin mappings from database');
|
||||||
|
return mappings;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,5 +82,9 @@ class HardwareVolumeService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
|
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close StreamControllers to prevent memory leaks
|
||||||
|
await _volumeUpController.close();
|
||||||
|
await _volumeDownController.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2540,6 +2540,7 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error parsing stream URI "$uri": $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2908,6 +2909,17 @@ class MusicAssistantAPI {
|
|||||||
_updateConnectionState(MAConnectionState.disconnected);
|
_updateConnectionState(MAConnectionState.disconnected);
|
||||||
await _channel?.sink.close();
|
await _channel?.sink.close();
|
||||||
_channel = null;
|
_channel = null;
|
||||||
|
// Complete all pending requests with an error before clearing
|
||||||
|
_cancelPendingRequests('Connection disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel all pending requests with an error message
|
||||||
|
void _cancelPendingRequests(String reason) {
|
||||||
|
for (final entry in _pendingRequests.entries) {
|
||||||
|
if (!entry.value.isCompleted) {
|
||||||
|
entry.value.completeError(Exception(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
_pendingRequests.clear();
|
_pendingRequests.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ class PcmAudioPlayer {
|
|||||||
// Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response
|
// Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response
|
||||||
static const int _feedThreshold = 5000;
|
static const int _feedThreshold = 5000;
|
||||||
|
|
||||||
|
// Maximum buffer size to prevent memory overflow (~10 seconds of audio)
|
||||||
|
// At 48kHz stereo 16-bit: 192KB/sec, so ~2MB for 10 seconds
|
||||||
|
// Each chunk is ~4KB, so max ~500 chunks
|
||||||
|
static const int _maxBufferChunks = 500;
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
int _framesPlayed = 0;
|
int _framesPlayed = 0;
|
||||||
int _bytesPlayed = 0;
|
int _bytesPlayed = 0;
|
||||||
@@ -201,6 +206,16 @@ class PcmAudioPlayer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Safely add audio data to buffer with overflow protection
|
||||||
|
void _addToBuffer(Uint8List audioData) {
|
||||||
|
// If buffer is full, drop oldest chunks to make room
|
||||||
|
while (_audioBuffer.length >= _maxBufferChunks) {
|
||||||
|
_audioBuffer.removeAt(0);
|
||||||
|
_logger.log('⚠️ PcmAudioPlayer: Buffer overflow - dropping oldest chunk');
|
||||||
|
}
|
||||||
|
_audioBuffer.add(audioData);
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle incoming audio data from the stream
|
/// Handle incoming audio data from the stream
|
||||||
void _onAudioData(Uint8List audioData) {
|
void _onAudioData(Uint8List audioData) {
|
||||||
// Ignore data if in error state
|
// Ignore data if in error state
|
||||||
@@ -215,7 +230,7 @@ class PcmAudioPlayer {
|
|||||||
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
||||||
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
||||||
_isAutoRecovering = true;
|
_isAutoRecovering = true;
|
||||||
_audioBuffer.add(audioData);
|
_addToBuffer(audioData);
|
||||||
|
|
||||||
// Trigger async recovery
|
// Trigger async recovery
|
||||||
_autoRecoverFromPause();
|
_autoRecoverFromPause();
|
||||||
@@ -224,7 +239,7 @@ class PcmAudioPlayer {
|
|||||||
|
|
||||||
// If already recovering or in transitional state, buffer the data
|
// If already recovering or in transitional state, buffer the data
|
||||||
if (_isAutoRecovering) {
|
if (_isAutoRecovering) {
|
||||||
_audioBuffer.add(audioData);
|
_addToBuffer(audioData);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,8 +247,8 @@ class PcmAudioPlayer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to buffer
|
// Add to buffer with overflow protection
|
||||||
_audioBuffer.add(audioData);
|
_addToBuffer(audioData);
|
||||||
|
|
||||||
// Start playback if not already started
|
// Start playback if not already started
|
||||||
if (!_isStarted && _state == PcmPlayerState.ready) {
|
if (!_isStarted && _state == PcmPlayerState.ready) {
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class PositionTracker {
|
|||||||
double _anchorPosition = 0.0; // Position in seconds at anchor time
|
double _anchorPosition = 0.0; // Position in seconds at anchor time
|
||||||
DateTime _anchorTime = DateTime.now(); // When we set the anchor
|
DateTime _anchorTime = DateTime.now(); // When we set the anchor
|
||||||
|
|
||||||
|
// Maximum time to interpolate from a stale anchor before capping
|
||||||
|
// After this, we stop adding time to prevent indefinite drift
|
||||||
|
static const Duration _maxAnchorAge = Duration(seconds: 30);
|
||||||
|
|
||||||
// Interpolation timer
|
// Interpolation timer
|
||||||
Timer? _interpolationTimer;
|
Timer? _interpolationTimer;
|
||||||
|
|
||||||
@@ -43,7 +47,13 @@ class PositionTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0;
|
final anchorAge = now.difference(_anchorTime);
|
||||||
|
|
||||||
|
// Cap interpolation at max anchor age to prevent indefinite drift
|
||||||
|
// If anchor is stale, return last known position (anchor + max age)
|
||||||
|
final elapsed = anchorAge > _maxAnchorAge
|
||||||
|
? _maxAnchorAge.inMilliseconds / 1000.0
|
||||||
|
: anchorAge.inMilliseconds / 1000.0;
|
||||||
final interpolated = _anchorPosition + elapsed;
|
final interpolated = _anchorPosition + elapsed;
|
||||||
|
|
||||||
// Cap at duration to prevent overflow
|
// Cap at duration to prevent overflow
|
||||||
@@ -61,7 +71,12 @@ class PositionTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0;
|
final anchorAge = now.difference(_anchorTime);
|
||||||
|
|
||||||
|
// Cap interpolation at max anchor age to prevent indefinite drift
|
||||||
|
final elapsed = anchorAge > _maxAnchorAge
|
||||||
|
? _maxAnchorAge.inMilliseconds / 1000.0
|
||||||
|
: anchorAge.inMilliseconds / 1000.0;
|
||||||
final interpolated = _anchorPosition + elapsed;
|
final interpolated = _anchorPosition + elapsed;
|
||||||
|
|
||||||
// Cap at duration
|
// Cap at duration
|
||||||
|
|||||||
@@ -151,7 +151,10 @@ class RecentlyPlayedService {
|
|||||||
if (item.metadata != null) {
|
if (item.metadata != null) {
|
||||||
try {
|
try {
|
||||||
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
|
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
// Corrupted metadata JSON - log and continue with null metadata
|
||||||
|
_logger.log('⚠️ Error parsing album metadata for ${item.mediaId}: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build metadata with images if available (for instant artwork display)
|
// Build metadata with images if available (for instant artwork display)
|
||||||
@@ -203,7 +206,10 @@ class RecentlyPlayedService {
|
|||||||
if (item.metadata != null) {
|
if (item.metadata != null) {
|
||||||
try {
|
try {
|
||||||
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
|
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
// Corrupted metadata JSON - log and continue with null metadata
|
||||||
|
_logger.log('⚠️ Error parsing audiobook metadata for ${item.mediaId}: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
audiobooks.add(Audiobook(
|
audiobooks.add(Audiobook(
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Service for securely storing sensitive data like passwords and tokens.
|
||||||
|
/// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android).
|
||||||
|
class SecureStorageService {
|
||||||
|
static const _storage = FlutterSecureStorage(
|
||||||
|
aOptions: AndroidOptions(
|
||||||
|
encryptedSharedPreferences: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keys for secure storage
|
||||||
|
static const String _keyAuthToken = 'secure_auth_token';
|
||||||
|
static const String _keyMaAuthToken = 'secure_ma_auth_token';
|
||||||
|
static const String _keyAuthCredentials = 'secure_auth_credentials';
|
||||||
|
static const String _keyPassword = 'secure_password';
|
||||||
|
static const String _keyAbsApiToken = 'secure_abs_api_token';
|
||||||
|
|
||||||
|
// Auth Token (for stream requests)
|
||||||
|
static Future<String?> getAuthToken() async {
|
||||||
|
return await _storage.read(key: _keyAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAuthToken(String? token) async {
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
await _storage.delete(key: _keyAuthToken);
|
||||||
|
} else {
|
||||||
|
await _storage.write(key: _keyAuthToken, value: token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Music Assistant Native Auth Token (long-lived token)
|
||||||
|
static Future<String?> getMaAuthToken() async {
|
||||||
|
return await _storage.read(key: _keyMaAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setMaAuthToken(String? token) async {
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
await _storage.delete(key: _keyMaAuthToken);
|
||||||
|
} else {
|
||||||
|
await _storage.write(key: _keyMaAuthToken, value: token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearMaAuthToken() async {
|
||||||
|
await _storage.delete(key: _keyMaAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth Credentials (serialized auth strategy credentials)
|
||||||
|
static Future<Map<String, dynamic>?> getAuthCredentials() async {
|
||||||
|
final json = await _storage.read(key: _keyAuthCredentials);
|
||||||
|
if (json == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAuthCredentials(Map<String, dynamic> credentials) async {
|
||||||
|
await _storage.write(key: _keyAuthCredentials, value: jsonEncode(credentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearAuthCredentials() async {
|
||||||
|
await _storage.delete(key: _keyAuthCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password
|
||||||
|
static Future<String?> getPassword() async {
|
||||||
|
return await _storage.read(key: _keyPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setPassword(String? password) async {
|
||||||
|
if (password == null || password.isEmpty) {
|
||||||
|
await _storage.delete(key: _keyPassword);
|
||||||
|
} else {
|
||||||
|
await _storage.write(key: _keyPassword, value: password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audiobookshelf API Token
|
||||||
|
static Future<String?> getAbsApiToken() async {
|
||||||
|
return await _storage.read(key: _keyAbsApiToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAbsApiToken(String? token) async {
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
await _storage.delete(key: _keyAbsApiToken);
|
||||||
|
} else {
|
||||||
|
await _storage.write(key: _keyAbsApiToken, value: token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all secure storage (used during logout)
|
||||||
|
static Future<void> clearAll() async {
|
||||||
|
await _storage.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate credentials from SharedPreferences to secure storage.
|
||||||
|
/// Call this once during app upgrade to migrate existing users.
|
||||||
|
static Future<void> migrateFromSharedPreferences() async {
|
||||||
|
// Import SharedPreferences for migration
|
||||||
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Migrate auth token
|
||||||
|
final authToken = prefs.getString('auth_token');
|
||||||
|
if (authToken != null && authToken.isNotEmpty) {
|
||||||
|
await setAuthToken(authToken);
|
||||||
|
await prefs.remove('auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate MA auth token
|
||||||
|
final maAuthToken = prefs.getString('ma_auth_token');
|
||||||
|
if (maAuthToken != null && maAuthToken.isNotEmpty) {
|
||||||
|
await setMaAuthToken(maAuthToken);
|
||||||
|
await prefs.remove('ma_auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate auth credentials
|
||||||
|
final authCredentials = prefs.getString('auth_credentials');
|
||||||
|
if (authCredentials != null && authCredentials.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(authCredentials) as Map<String, dynamic>;
|
||||||
|
await setAuthCredentials(decoded);
|
||||||
|
await prefs.remove('auth_credentials');
|
||||||
|
} catch (_) {
|
||||||
|
// Invalid JSON, just remove it
|
||||||
|
await prefs.remove('auth_credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate password
|
||||||
|
final password = prefs.getString('password');
|
||||||
|
if (password != null && password.isNotEmpty) {
|
||||||
|
await setPassword(password);
|
||||||
|
await prefs.remove('password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate ABS API token
|
||||||
|
final absApiToken = prefs.getString('abs_api_token');
|
||||||
|
if (absApiToken != null && absApiToken.isNotEmpty) {
|
||||||
|
await setAbsApiToken(absApiToken);
|
||||||
|
await prefs.remove('abs_api_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,9 @@ class SendspinService {
|
|||||||
// Auth token for proxy authentication (MA 2.7.1+)
|
// Auth token for proxy authentication (MA 2.7.1+)
|
||||||
String? _authToken;
|
String? _authToken;
|
||||||
|
|
||||||
|
// Connection deduplication guard - prevents multiple concurrent auth attempts
|
||||||
|
Completer<bool>? _connectionInProgress;
|
||||||
|
|
||||||
SendspinService(this.serverUrl);
|
SendspinService(this.serverUrl);
|
||||||
|
|
||||||
/// Set the MA auth token for proxy authentication
|
/// Set the MA auth token for proxy authentication
|
||||||
@@ -100,6 +103,13 @@ class SendspinService {
|
|||||||
if (_isDisposed) return false;
|
if (_isDisposed) return false;
|
||||||
if (_state == SendspinConnectionState.connected) return true;
|
if (_state == SendspinConnectionState.connected) return true;
|
||||||
|
|
||||||
|
// Deduplicate concurrent connection attempts
|
||||||
|
if (_connectionInProgress != null) {
|
||||||
|
_logger.log('Sendspin: Connection already in progress, waiting...');
|
||||||
|
return _connectionInProgress!.future;
|
||||||
|
}
|
||||||
|
_connectionInProgress = Completer<bool>();
|
||||||
|
|
||||||
_updateState(SendspinConnectionState.connecting);
|
_updateState(SendspinConnectionState.connecting);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -126,13 +136,19 @@ class SendspinService {
|
|||||||
if (!connected) {
|
if (!connected) {
|
||||||
_logger.log('Sendspin: External proxy connection failed');
|
_logger.log('Sendspin: External proxy connection failed');
|
||||||
_updateState(SendspinConnectionState.error);
|
_updateState(SendspinConnectionState.error);
|
||||||
|
_connectionInProgress?.complete(false);
|
||||||
|
_connectionInProgress = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_connectionInProgress?.complete(true);
|
||||||
|
_connectionInProgress = null;
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Sendspin: Connection error: $e');
|
_logger.log('Sendspin: Connection error: $e');
|
||||||
_updateState(SendspinConnectionState.error);
|
_updateState(SendspinConnectionState.error);
|
||||||
|
_connectionInProgress?.complete(false);
|
||||||
|
_connectionInProgress = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,6 +158,13 @@ class SendspinService {
|
|||||||
if (_isDisposed) return false;
|
if (_isDisposed) return false;
|
||||||
if (_state == SendspinConnectionState.connected) return true;
|
if (_state == SendspinConnectionState.connected) return true;
|
||||||
|
|
||||||
|
// Deduplicate concurrent connection attempts
|
||||||
|
if (_connectionInProgress != null) {
|
||||||
|
_logger.log('Sendspin: Connection already in progress, waiting...');
|
||||||
|
return _connectionInProgress!.future;
|
||||||
|
}
|
||||||
|
_connectionInProgress = Completer<bool>();
|
||||||
|
|
||||||
_updateState(SendspinConnectionState.connecting);
|
_updateState(SendspinConnectionState.connecting);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -154,13 +177,19 @@ class SendspinService {
|
|||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
_updateState(SendspinConnectionState.error);
|
_updateState(SendspinConnectionState.error);
|
||||||
|
_connectionInProgress?.complete(false);
|
||||||
|
_connectionInProgress = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_connectionInProgress?.complete(true);
|
||||||
|
_connectionInProgress = null;
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Sendspin: Connection error: $e');
|
_logger.log('Sendspin: Connection error: $e');
|
||||||
_updateState(SendspinConnectionState.error);
|
_updateState(SendspinConnectionState.error);
|
||||||
|
_connectionInProgress?.complete(false);
|
||||||
|
_connectionInProgress = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'device_id_service.dart';
|
import 'device_id_service.dart';
|
||||||
|
import 'secure_storage_service.dart';
|
||||||
|
|
||||||
class SettingsService {
|
class SettingsService {
|
||||||
static const String _keyServerUrl = 'server_url';
|
static const String _keyServerUrl = 'server_url';
|
||||||
@@ -127,66 +128,44 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get authentication token for stream requests
|
// Get authentication token for stream requests (securely stored)
|
||||||
static Future<String?> getAuthToken() async {
|
static Future<String?> getAuthToken() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
return await SecureStorageService.getAuthToken();
|
||||||
return prefs.getString(_keyAuthToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set authentication token for stream requests
|
// Set authentication token for stream requests (securely stored)
|
||||||
static Future<void> setAuthToken(String? token) async {
|
static Future<void> setAuthToken(String? token) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await SecureStorageService.setAuthToken(token);
|
||||||
if (token == null || token.isEmpty) {
|
|
||||||
await prefs.remove(_keyAuthToken);
|
|
||||||
} else {
|
|
||||||
await prefs.setString(_keyAuthToken, token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Music Assistant native auth token (long-lived token)
|
// Get Music Assistant native auth token (long-lived token, securely stored)
|
||||||
static Future<String?> getMaAuthToken() async {
|
static Future<String?> getMaAuthToken() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
return await SecureStorageService.getMaAuthToken();
|
||||||
return prefs.getString(_keyMaAuthToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Music Assistant native auth token (long-lived token)
|
// Set Music Assistant native auth token (long-lived token, securely stored)
|
||||||
static Future<void> setMaAuthToken(String? token) async {
|
static Future<void> setMaAuthToken(String? token) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await SecureStorageService.setMaAuthToken(token);
|
||||||
if (token == null || token.isEmpty) {
|
|
||||||
await prefs.remove(_keyMaAuthToken);
|
|
||||||
} else {
|
|
||||||
await prefs.setString(_keyMaAuthToken, token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear Music Assistant native auth token
|
// Clear Music Assistant native auth token
|
||||||
static Future<void> clearMaAuthToken() async {
|
static Future<void> clearMaAuthToken() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await SecureStorageService.clearMaAuthToken();
|
||||||
await prefs.remove(_keyMaAuthToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get authentication credentials (serialized auth strategy credentials)
|
// Get authentication credentials (serialized auth strategy credentials, securely stored)
|
||||||
static Future<Map<String, dynamic>?> getAuthCredentials() async {
|
static Future<Map<String, dynamic>?> getAuthCredentials() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
return await SecureStorageService.getAuthCredentials();
|
||||||
final json = prefs.getString(_keyAuthCredentials);
|
|
||||||
if (json == null) return null;
|
|
||||||
try {
|
|
||||||
return jsonDecode(json) as Map<String, dynamic>;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set authentication credentials (serialized auth strategy credentials)
|
// Set authentication credentials (serialized auth strategy credentials, securely stored)
|
||||||
static Future<void> setAuthCredentials(Map<String, dynamic> credentials) async {
|
static Future<void> setAuthCredentials(Map<String, dynamic> credentials) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await SecureStorageService.setAuthCredentials(credentials);
|
||||||
await prefs.setString(_keyAuthCredentials, jsonEncode(credentials));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear authentication credentials
|
// Clear authentication credentials
|
||||||
static Future<void> clearAuthCredentials() async {
|
static Future<void> clearAuthCredentials() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await SecureStorageService.clearAuthCredentials();
|
||||||
await prefs.remove(_keyAuthCredentials);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get username for authentication
|
// Get username for authentication
|
||||||
@@ -205,20 +184,14 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get password for authentication
|
// Get password for authentication (securely stored)
|
||||||
static Future<String?> getPassword() async {
|
static Future<String?> getPassword() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
return await SecureStorageService.getPassword();
|
||||||
return prefs.getString(_keyPassword);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set password for authentication
|
// Set password for authentication (securely stored)
|
||||||
static Future<void> setPassword(String? password) async {
|
static Future<void> setPassword(String? password) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await SecureStorageService.setPassword(password);
|
||||||
if (password == null || password.isEmpty) {
|
|
||||||
await prefs.remove(_keyPassword);
|
|
||||||
} else {
|
|
||||||
await prefs.setString(_keyPassword, password);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get built-in player ID (persistent UUID for this device)
|
// Get built-in player ID (persistent UUID for this device)
|
||||||
@@ -409,6 +382,13 @@ class SettingsService {
|
|||||||
static Future<void> clearSettings() async {
|
static Future<void> clearSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
|
await SecureStorageService.clearAll(); // Also clear secure storage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate credentials from old SharedPreferences storage to secure storage.
|
||||||
|
/// Should be called once during app startup for existing users.
|
||||||
|
static Future<void> migrateToSecureStorage() async {
|
||||||
|
await SecureStorageService.migrateFromSharedPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Home Screen Row Settings (Main rows - default on)
|
// Home Screen Row Settings (Main rows - default on)
|
||||||
@@ -674,13 +654,11 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<String?> getAbsApiToken() async {
|
static Future<String?> getAbsApiToken() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
return await SecureStorageService.getAbsApiToken();
|
||||||
return prefs.getString(_keyAbsApiToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setAbsApiToken(String token) async {
|
static Future<void> setAbsApiToken(String token) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await SecureStorageService.setAbsApiToken(token);
|
||||||
await prefs.setString(_keyAbsApiToken, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> getAbsEnabled() async {
|
static Future<bool> getAbsEnabled() async {
|
||||||
@@ -696,8 +674,8 @@ class SettingsService {
|
|||||||
static Future<void> clearAbsSettings() async {
|
static Future<void> clearAbsSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_keyAbsServerUrl);
|
await prefs.remove(_keyAbsServerUrl);
|
||||||
await prefs.remove(_keyAbsApiToken);
|
|
||||||
await prefs.remove(_keyAbsEnabled);
|
await prefs.remove(_keyAbsEnabled);
|
||||||
|
await SecureStorageService.setAbsApiToken(null); // Clear from secure storage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audiobookshelf Library Settings (via MA browse API)
|
// Audiobookshelf Library Settings (via MA browse API)
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
|
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
|
||||||
|
|
||||||
// Dimensions
|
// Dimensions
|
||||||
static const double _collapsedHeight = 64.0;
|
static double get _collapsedHeight => MiniPlayerLayout.height;
|
||||||
static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar)
|
static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar)
|
||||||
static const double _collapsedBorderRadius = 16.0;
|
static const double _collapsedBorderRadius = 16.0;
|
||||||
static const double _collapsedArtSize = 64.0;
|
static double get _collapsedArtSize => MiniPlayerLayout.artSize;
|
||||||
static const double _bottomNavHeight = 56.0;
|
static const double _bottomNavHeight = 56.0;
|
||||||
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
|
static const double _edgeDeadZone = 40.0; // Dead zone for Android back gesture
|
||||||
|
|
||||||
@@ -104,6 +104,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// When true, we hide main content and show peek content at center
|
// When true, we hide main content and show peek content at center
|
||||||
bool _inTransition = false;
|
bool _inTransition = false;
|
||||||
|
|
||||||
|
// Volume swipe state (only active when device reveal is visible)
|
||||||
|
bool _isDraggingVolume = false;
|
||||||
|
double _dragVolumeLevel = 0.0;
|
||||||
|
int _lastVolumeUpdateTime = 0;
|
||||||
|
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
||||||
|
|
||||||
// Track favorite state for current track
|
// Track favorite state for current track
|
||||||
bool _isCurrentTrackFavorite = false;
|
bool _isCurrentTrackFavorite = false;
|
||||||
String? _lastTrackUri; // Track which track we last checked favorite status for
|
String? _lastTrackUri; // Track which track we last checked favorite status for
|
||||||
@@ -1095,6 +1101,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
right: _collapsedMargin,
|
right: _collapsedMargin,
|
||||||
bottom: adjustedBottomOffset,
|
bottom: adjustedBottomOffset,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
|
// Tap to dismiss player reveal (matching _buildMorphingPlayer behavior)
|
||||||
|
onTap: widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : null,
|
||||||
onVerticalDragUpdate: (details) {
|
onVerticalDragUpdate: (details) {
|
||||||
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
|
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
|
||||||
widget.onRevealPlayers!();
|
widget.onRevealPlayers!();
|
||||||
@@ -1236,7 +1244,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final expandedArtLeft = (screenSize.width - expandedArtSize) / 2;
|
final expandedArtLeft = (screenSize.width - expandedArtSize) / 2;
|
||||||
final artLeft = _lerpDouble(collapsedArtLeft, expandedArtLeft, t);
|
final artLeft = _lerpDouble(collapsedArtLeft, expandedArtLeft, t);
|
||||||
|
|
||||||
final collapsedArtTop = 0.0;
|
final collapsedArtTop = (_collapsedHeight - _collapsedArtSize) / 2; // Center vertically
|
||||||
final expandedArtTop = topPadding + headerHeight + 16;
|
final expandedArtTop = topPadding + headerHeight + 16;
|
||||||
final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t);
|
final artTop = _lerpDouble(collapsedArtTop, expandedArtTop, t);
|
||||||
|
|
||||||
@@ -1312,6 +1320,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
|
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
|
||||||
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
|
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
|
||||||
|
|
||||||
|
// Player name - only visible when collapsed (third line)
|
||||||
|
final playerNameTop = MiniPlayerLayout.tertiaryTop;
|
||||||
|
|
||||||
// Album - subtle, below artist
|
// Album - subtle, below artist
|
||||||
final expandedAlbumTop = expandedArtistTop + artistHeight + artistToAlbumGap;
|
final expandedAlbumTop = expandedArtistTop + artistHeight + artistToAlbumGap;
|
||||||
|
|
||||||
@@ -1350,8 +1361,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
// Use translucent to allow child widgets (like buttons) to receive taps
|
// Use translucent to allow child widgets (like buttons) to receive taps
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
// Only handle tap when collapsed - when expanded, let children handle their own taps
|
// Handle tap: when device list is visible, dismiss it; when collapsed, expand
|
||||||
onTap: isExpanded ? null : expand,
|
onTap: isExpanded ? null : (widget.isDeviceRevealVisible ? GlobalPlayerOverlay.dismissPlayerReveal : expand),
|
||||||
onVerticalDragStart: (details) {
|
onVerticalDragStart: (details) {
|
||||||
// For expanded player or queue panel: start tracking immediately
|
// For expanded player or queue panel: start tracking immediately
|
||||||
// For collapsed player: defer decision until we know swipe direction
|
// For collapsed player: defer decision until we know swipe direction
|
||||||
@@ -1395,9 +1406,39 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
},
|
},
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
_horizontalDragStartX = details.globalPosition.dx;
|
_horizontalDragStartX = details.globalPosition.dx;
|
||||||
|
// Start volume drag if device reveal is visible
|
||||||
|
if (widget.isDeviceRevealVisible && !isExpanded) {
|
||||||
|
final currentVolume = (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0;
|
||||||
|
setState(() {
|
||||||
|
_isDraggingVolume = true;
|
||||||
|
_dragVolumeLevel = currentVolume;
|
||||||
|
});
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
// Only handle in collapsed mode with multiple players
|
// Volume swipe when device reveal is visible
|
||||||
|
if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) {
|
||||||
|
// Directional volume: drag right = increase, drag left = decrease
|
||||||
|
// Multiple swipes accumulate - each starts from current volume
|
||||||
|
final dragDelta = details.delta.dx;
|
||||||
|
final volumeDelta = dragDelta / collapsedWidth;
|
||||||
|
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||||
|
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
|
||||||
|
setState(() {
|
||||||
|
_dragVolumeLevel = newVolume;
|
||||||
|
});
|
||||||
|
// Throttle API calls to prevent flooding
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (now - _lastVolumeUpdateTime >= _volumeThrottleMs) {
|
||||||
|
_lastVolumeUpdateTime = now;
|
||||||
|
maProvider.setVolume(selectedPlayer.playerId, (newVolume * 100).round());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle player swipe in collapsed mode with multiple players
|
||||||
if (isExpanded || !hasMultiplePlayers) return;
|
if (isExpanded || !hasMultiplePlayers) return;
|
||||||
|
|
||||||
// Ignore drags that started in the edge dead zone
|
// Ignore drags that started in the edge dead zone
|
||||||
@@ -1410,6 +1451,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
_handleHorizontalDragUpdate(details, maProvider, collapsedWidth);
|
_handleHorizontalDragUpdate(details, maProvider, collapsedWidth);
|
||||||
},
|
},
|
||||||
onHorizontalDragEnd: (details) {
|
onHorizontalDragEnd: (details) {
|
||||||
|
// End volume drag
|
||||||
|
if (_isDraggingVolume) {
|
||||||
|
// Send final volume on release
|
||||||
|
maProvider.setVolume(selectedPlayer.playerId, (_dragVolumeLevel * 100).round());
|
||||||
|
setState(() {
|
||||||
|
_isDraggingVolume = false;
|
||||||
|
});
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
_horizontalDragStartX = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ignore swipes that started near the edges (Android back gesture zone)
|
// Ignore swipes that started near the edges (Android back gesture zone)
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
final startedInDeadZone = _horizontalDragStartX != null &&
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
||||||
@@ -1434,12 +1487,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
// Use foregroundDecoration for border so it renders ON TOP of content
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
// This prevents the album art from clipping the yellow synced border
|
||||||
border: selectedPlayer.isGrouped && t < 0.5
|
foregroundDecoration: maProvider.isPlayerManuallySynced(selectedPlayer.playerId) && t < 0.5
|
||||||
? Border.all(color: _groupBorderColor, width: 1.5)
|
? BoxDecoration(
|
||||||
: null,
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
),
|
border: Border.all(color: _groupBorderColor, width: 1.5),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
@@ -1609,10 +1664,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: titleWidth,
|
width: titleWidth,
|
||||||
child: Text(
|
child: Text(
|
||||||
// Show player name when device reveal visible and collapsed
|
currentTrack.name,
|
||||||
(widget.isDeviceRevealVisible && t < 0.5)
|
|
||||||
? selectedPlayer.name
|
|
||||||
: currentTrack.name,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor,
|
color: textColor,
|
||||||
fontSize: titleFontSize,
|
fontSize: titleFontSize,
|
||||||
@@ -1659,6 +1711,26 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Player name - third line, only visible when collapsed
|
||||||
|
// Same size as artist, same color as track
|
||||||
|
if (t < 0.5 && !(_inTransition && t < 0.1 && _peekPlayer != null) && !(widget.isHintVisible && t < 0.5))
|
||||||
|
Positioned(
|
||||||
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
|
top: playerNameTop,
|
||||||
|
child: SizedBox(
|
||||||
|
width: titleWidth,
|
||||||
|
child: Text(
|
||||||
|
selectedPlayer.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(1.0 - t * 2), // Fade out as expanding
|
||||||
|
fontSize: MiniPlayerLayout.tertiaryFontSize,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Album name OR Chapter name (expanded only)
|
// Album name OR Chapter name (expanded only)
|
||||||
// GPU PERF: Use color alpha instead of Opacity widget
|
// GPU PERF: Use color alpha instead of Opacity widget
|
||||||
// For audiobooks: show current chapter; for music: show album
|
// For audiobooks: show current chapter; for music: show album
|
||||||
@@ -1828,7 +1900,60 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
t: t,
|
t: t,
|
||||||
expandedElementsOpacity: expandedElementsOpacity,
|
expandedElementsOpacity: expandedElementsOpacity,
|
||||||
)
|
)
|
||||||
: Row(
|
// When device reveal is visible (player list shown), use compact controls
|
||||||
|
// like PlayerCard: Play, Next, Power - matching other players in the list
|
||||||
|
: widget.isDeviceRevealVisible && t < 0.5
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
// Play/Pause - compact like PlayerCard
|
||||||
|
// Touch target increased to 44dp for accessibility
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(3, 0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
selectedPlayer.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
onPressed: () => maProvider.playPauseSelectedPlayer(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Skip next - nudged right like PlayerCard
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(6, 0),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.skip_next_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
onPressed: () => maProvider.nextTrackSelectedPlayer(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Power button - smallest, like PlayerCard
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.power_settings_new_rounded,
|
||||||
|
color: selectedPlayer.powered ? textColor : textColor.withOpacity(0.5),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: () => maProvider.togglePower(selectedPlayer.playerId),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
|
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
@@ -2019,6 +2144,32 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Volume swipe overlay (covers entire mini player when dragging volume)
|
||||||
|
// Only visible when device reveal is open and user is dragging
|
||||||
|
// Positioned last in Stack so it renders ON TOP of all content
|
||||||
|
if (_isDraggingVolume && t < 0.5)
|
||||||
|
Positioned.fill(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Unfilled (darker) background
|
||||||
|
Container(color: collapsedBgUnplayed),
|
||||||
|
// Filled (lighter) portion based on volume
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
|
||||||
|
heightFactor: 1.0,
|
||||||
|
child: Container(color: collapsedBg),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2056,12 +2207,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final hasTrack = _peekTrack != null && peekImageUrl != null;
|
final hasTrack = _peekTrack != null && peekImageUrl != null;
|
||||||
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
|
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
|
||||||
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
|
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
|
||||||
|
// Show player name as third line only when playing
|
||||||
|
final peekPlayerName = hasTrack ? (peekPlayer?.name ?? '') : null;
|
||||||
|
|
||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(peekBaseOffset, 0),
|
offset: Offset(peekBaseOffset, 0),
|
||||||
child: MiniPlayerContent(
|
child: MiniPlayerContent(
|
||||||
primaryText: peekTrackName,
|
primaryText: peekTrackName,
|
||||||
secondaryText: peekArtistName,
|
secondaryText: peekArtistName,
|
||||||
|
tertiaryText: peekPlayerName,
|
||||||
imageUrl: hasTrack ? peekImageUrl : null,
|
imageUrl: hasTrack ? peekImageUrl : null,
|
||||||
playerName: peekPlayer?.name ?? '',
|
playerName: peekPlayer?.name ?? '',
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../providers/navigation_provider.dart';
|
|||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import 'expandable_player.dart';
|
import 'expandable_player.dart';
|
||||||
|
import 'player/mini_player_content.dart' show MiniPlayerLayout;
|
||||||
import 'player/player_reveal_overlay.dart';
|
import 'player/player_reveal_overlay.dart';
|
||||||
|
|
||||||
/// Cached color with contrast adjustment
|
/// Cached color with contrast adjustment
|
||||||
@@ -56,14 +57,14 @@ class BottomSpacing {
|
|||||||
/// Height of the bottom navigation bar
|
/// Height of the bottom navigation bar
|
||||||
static const double navBarHeight = 56.0;
|
static const double navBarHeight = 56.0;
|
||||||
|
|
||||||
/// Height of mini player when visible (64px height + 12px margin)
|
/// Height of mini player when visible (height + 12px margin)
|
||||||
static const double miniPlayerHeight = 76.0;
|
static double get miniPlayerHeight => MiniPlayerLayout.height + 12.0;
|
||||||
|
|
||||||
/// Space needed when only nav bar is visible (with some extra padding)
|
/// Space needed when only nav bar is visible (with some extra padding)
|
||||||
static const double navBarOnly = navBarHeight + 16.0;
|
static const double navBarOnly = navBarHeight + 16.0;
|
||||||
|
|
||||||
/// Space needed when mini player is also visible
|
/// Space needed when mini player is also visible
|
||||||
static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 22.0;
|
static double get withMiniPlayer => navBarHeight + miniPlayerHeight + 22.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
|
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
|
||||||
|
|||||||
@@ -3,18 +3,24 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
|
|
||||||
/// Shared constants for mini player layout
|
/// Shared constants for mini player layout
|
||||||
class MiniPlayerLayout {
|
class MiniPlayerLayout {
|
||||||
static const double height = 64.0;
|
static const double height = 72.0;
|
||||||
static const double artSize = 64.0;
|
static const double artSize = 72.0;
|
||||||
static const double textLeftOffset = 10.0; // Gap between art and text
|
static const double textLeftOffset = 10.0; // Gap between art and text
|
||||||
static const double textLeft = artSize + textLeftOffset; // 74px
|
static const double textLeft = artSize + textLeftOffset; // 82px
|
||||||
static const double primaryTop = 13.0;
|
// 3-line layout: track, artist, player
|
||||||
static const double secondaryTop = 33.0;
|
static const double primaryTop = 7.0;
|
||||||
|
static const double secondaryTop = 27.0;
|
||||||
|
static const double tertiaryTop = 46.0;
|
||||||
|
// 2-line layout (centered): player name, hint
|
||||||
|
static const double primaryTop2Line = 16.0;
|
||||||
|
static const double secondaryTop2Line = 36.0;
|
||||||
static const double textRightPadding = 12.0;
|
static const double textRightPadding = 12.0;
|
||||||
static const double iconSize = 28.0;
|
static const double iconSize = 28.0;
|
||||||
static const double iconOpacity = 0.4;
|
static const double iconOpacity = 0.4;
|
||||||
static const double secondaryTextOpacity = 0.6;
|
static const double secondaryTextOpacity = 0.6;
|
||||||
static const double primaryFontSize = 16.0;
|
static const double primaryFontSize = 16.0;
|
||||||
static const double secondaryFontSize = 14.0;
|
static const double secondaryFontSize = 14.0;
|
||||||
|
static const double tertiaryFontSize = 14.0;
|
||||||
static const FontWeight primaryFontWeight = FontWeight.w500;
|
static const FontWeight primaryFontWeight = FontWeight.w500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +36,10 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
/// If null, primary text will be vertically centered
|
/// If null, primary text will be vertically centered
|
||||||
final String? secondaryText;
|
final String? secondaryText;
|
||||||
|
|
||||||
|
/// Tertiary text line (player name when playing)
|
||||||
|
/// If provided, uses 3-line layout; if null, uses centered 2-line layout
|
||||||
|
final String? tertiaryText;
|
||||||
|
|
||||||
/// Album art URL - if null, shows device icon
|
/// Album art URL - if null, shows device icon
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
|
|
||||||
@@ -61,6 +71,7 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.primaryText,
|
required this.primaryText,
|
||||||
this.secondaryText,
|
this.secondaryText,
|
||||||
|
this.tertiaryText,
|
||||||
this.imageUrl,
|
this.imageUrl,
|
||||||
required this.playerName,
|
required this.playerName,
|
||||||
required this.backgroundColor,
|
required this.backgroundColor,
|
||||||
@@ -75,8 +86,13 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
|
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
|
||||||
|
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
|
||||||
final slidePixels = slideOffset * width;
|
final slidePixels = slideOffset * width;
|
||||||
|
|
||||||
|
// Use 3-line layout when tertiary exists, 2-line centered otherwise
|
||||||
|
final primaryTop = hasTertiaryLine ? MiniPlayerLayout.primaryTop : MiniPlayerLayout.primaryTop2Line;
|
||||||
|
final secondaryTop = hasTertiaryLine ? MiniPlayerLayout.secondaryTop : MiniPlayerLayout.secondaryTop2Line;
|
||||||
|
|
||||||
// Calculate text width (leaves room for right padding)
|
// Calculate text width (leaves room for right padding)
|
||||||
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
|
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
|
||||||
|
|
||||||
@@ -99,10 +115,10 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
child: Container(color: backgroundColor),
|
child: Container(color: backgroundColor),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Art / Icon area
|
// Art / Icon area - centered vertically
|
||||||
Positioned(
|
Positioned(
|
||||||
left: slidePixels,
|
left: slidePixels,
|
||||||
top: 0,
|
top: (MiniPlayerLayout.height - MiniPlayerLayout.artSize) / 2,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: MiniPlayerLayout.artSize,
|
width: MiniPlayerLayout.artSize,
|
||||||
height: MiniPlayerLayout.artSize,
|
height: MiniPlayerLayout.artSize,
|
||||||
@@ -125,7 +141,7 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: MiniPlayerLayout.textLeft + slidePixels,
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
top: hasSecondaryLine
|
top: hasSecondaryLine
|
||||||
? MiniPlayerLayout.primaryTop
|
? primaryTop
|
||||||
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
|
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
|
||||||
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -144,7 +160,7 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
if (hasSecondaryLine)
|
if (hasSecondaryLine)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: MiniPlayerLayout.textLeft + slidePixels,
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
top: MiniPlayerLayout.secondaryTop,
|
top: secondaryTop,
|
||||||
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||||
child: isHint
|
child: isHint
|
||||||
? Row(
|
? Row(
|
||||||
@@ -179,6 +195,23 @@ class MiniPlayerContent extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Tertiary text line (player name, only for 3-line layout)
|
||||||
|
if (hasTertiaryLine)
|
||||||
|
Positioned(
|
||||||
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
|
top: MiniPlayerLayout.tertiaryTop,
|
||||||
|
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||||
|
child: Text(
|
||||||
|
tertiaryText!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: MiniPlayerLayout.tertiaryFontSize,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+273
-139
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../../providers/music_assistant_provider.dart';
|
import '../../providers/music_assistant_provider.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
@@ -6,7 +7,8 @@ import '../../l10n/app_localizations.dart';
|
|||||||
|
|
||||||
/// A player card that matches the mini player's visual style.
|
/// A player card that matches the mini player's visual style.
|
||||||
/// Used in the player reveal overlay when swiping down or tapping the device button.
|
/// Used in the player reveal overlay when swiping down or tapping the device button.
|
||||||
class PlayerCard extends StatelessWidget {
|
/// Supports horizontal swipe to adjust volume with a two-tone overlay.
|
||||||
|
class PlayerCard extends StatefulWidget {
|
||||||
final dynamic player;
|
final dynamic player;
|
||||||
final dynamic trackInfo;
|
final dynamic trackInfo;
|
||||||
final String? albumArtUrl;
|
final String? albumArtUrl;
|
||||||
@@ -20,6 +22,7 @@ class PlayerCard extends StatelessWidget {
|
|||||||
final VoidCallback? onPlayPause;
|
final VoidCallback? onPlayPause;
|
||||||
final VoidCallback? onSkipNext;
|
final VoidCallback? onSkipNext;
|
||||||
final VoidCallback? onPower;
|
final VoidCallback? onPower;
|
||||||
|
final ValueChanged<double>? onVolumeChange;
|
||||||
|
|
||||||
// Pastel yellow for grouped players
|
// Pastel yellow for grouped players
|
||||||
static const Color groupBorderColor = Color(0xFFFFF59D);
|
static const Color groupBorderColor = Color(0xFFFFF59D);
|
||||||
@@ -39,174 +42,305 @@ class PlayerCard extends StatelessWidget {
|
|||||||
this.onPlayPause,
|
this.onPlayPause,
|
||||||
this.onSkipNext,
|
this.onSkipNext,
|
||||||
this.onPower,
|
this.onPower,
|
||||||
|
this.onVolumeChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlayerCard> createState() => _PlayerCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerCardState extends State<PlayerCard> {
|
||||||
|
bool _isDraggingVolume = false;
|
||||||
|
double _dragVolumeLevel = 0.0;
|
||||||
|
double _cardWidth = 0.0;
|
||||||
|
int _lastVolumeUpdateTime = 0;
|
||||||
|
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Match mini player dimensions
|
// Match mini player dimensions
|
||||||
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
|
const double cardHeight = Dimensions.miniPlayerHeight;
|
||||||
const double artSize = cardHeight;
|
const double artSize = cardHeight;
|
||||||
const double borderRadius = Radii.xl; // 16px - same as mini player
|
const double borderRadius = Radii.xl; // 16px - same as mini player
|
||||||
|
|
||||||
return GestureDetector(
|
// Colors for volume overlay (same as mini player progress bar)
|
||||||
onTap: onTap,
|
final filledColor = widget.backgroundColor;
|
||||||
onLongPress: onLongPress,
|
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
|
||||||
child: Container(
|
|
||||||
height: cardHeight,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: backgroundColor,
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
border: isGrouped
|
|
||||||
? Border.all(color: groupBorderColor, width: 1.5)
|
|
||||||
: null,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.2),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Album art or speaker icon - same size for consistent text alignment
|
|
||||||
SizedBox(
|
|
||||||
width: artSize,
|
|
||||||
height: artSize,
|
|
||||||
child: albumArtUrl != null
|
|
||||||
? CachedNetworkImage(
|
|
||||||
imageUrl: albumArtUrl!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
memCacheWidth: 128,
|
|
||||||
memCacheHeight: 128,
|
|
||||||
fadeInDuration: Duration.zero,
|
|
||||||
fadeOutDuration: Duration.zero,
|
|
||||||
placeholder: (_, __) => _buildSpeakerIcon(),
|
|
||||||
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
|
|
||||||
)
|
|
||||||
: _buildSpeakerIcon(),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Player info
|
return LayoutBuilder(
|
||||||
Expanded(
|
builder: (context, constraints) {
|
||||||
child: Padding(
|
_cardWidth = constraints.maxWidth;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Column(
|
return GestureDetector(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
onTap: _isDraggingVolume ? null : widget.onTap,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onLongPress: _isDraggingVolume ? null : widget.onLongPress,
|
||||||
children: [
|
onHorizontalDragStart: _onDragStart,
|
||||||
// Player name
|
onHorizontalDragUpdate: _onDragUpdate,
|
||||||
Row(
|
onHorizontalDragEnd: _onDragEnd,
|
||||||
children: [
|
onHorizontalDragCancel: _onDragCancel,
|
||||||
// Status indicator
|
// Use Stack to render border ON TOP of content, preventing album art clipping
|
||||||
Container(
|
child: Stack(
|
||||||
width: 8,
|
children: [
|
||||||
height: 8,
|
// Main card content (hidden when dragging volume)
|
||||||
margin: const EdgeInsets.only(right: 8),
|
if (!_isDraggingVolume)
|
||||||
decoration: BoxDecoration(
|
_buildCardContent(cardHeight, artSize, borderRadius),
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: _getStatusColor(),
|
// Volume overlay (shown when dragging)
|
||||||
),
|
if (_isDraggingVolume)
|
||||||
),
|
Container(
|
||||||
Expanded(
|
height: cardHeight,
|
||||||
child: Text(
|
decoration: BoxDecoration(
|
||||||
player.name,
|
color: unfilledColor,
|
||||||
style: TextStyle(
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
color: textColor,
|
boxShadow: [
|
||||||
fontSize: 16,
|
BoxShadow(
|
||||||
fontWeight: FontWeight.w500,
|
color: Colors.black.withOpacity(0.2),
|
||||||
fontFamily: 'Roboto',
|
blurRadius: 8,
|
||||||
decoration: TextDecoration.none,
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
],
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
),
|
clipBehavior: Clip.antiAlias,
|
||||||
),
|
child: Align(
|
||||||
],
|
alignment: Alignment.centerLeft,
|
||||||
),
|
child: FractionallySizedBox(
|
||||||
const SizedBox(height: 2),
|
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
|
||||||
// Track name or status
|
heightFactor: 1.0,
|
||||||
Text(
|
child: Container(
|
||||||
_getSubtitle(context),
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
color: filledColor,
|
||||||
color: textColor.withOpacity(0.6),
|
borderRadius: BorderRadius.only(
|
||||||
fontSize: 14,
|
topLeft: Radius.circular(borderRadius),
|
||||||
fontFamily: 'Roboto',
|
bottomLeft: Radius.circular(borderRadius),
|
||||||
decoration: TextDecoration.none,
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Transport controls - compact sizing to align with mini player
|
|
||||||
// Play/Pause and Next only shown when powered with content
|
|
||||||
if (player.available && player.powered && trackInfo != null) ...[
|
|
||||||
// Play/Pause - slight nudge right
|
|
||||||
Transform.translate(
|
|
||||||
offset: const Offset(3, 0),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
child: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
|
||||||
color: textColor,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
onPressed: onPlayPause,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Border overlay - renders ON TOP to prevent clipping by album art
|
||||||
|
if (widget.isGrouped)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
border: Border.all(color: PlayerCard.groupBorderColor, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCardContent(double cardHeight, double artSize, double borderRadius) {
|
||||||
|
return Container(
|
||||||
|
height: cardHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.2),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Album art or speaker icon - same size for consistent text alignment
|
||||||
|
SizedBox(
|
||||||
|
width: artSize,
|
||||||
|
height: artSize,
|
||||||
|
child: widget.albumArtUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: widget.albumArtUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 128,
|
||||||
|
memCacheHeight: 128,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => _buildSpeakerIcon(),
|
||||||
|
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
|
||||||
|
)
|
||||||
|
: _buildSpeakerIcon(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Player info
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Player name
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Status indicator
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _getStatusColor(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.player.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.textColor,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
decoration: TextDecoration.none,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Track name or status
|
||||||
|
Text(
|
||||||
|
_getSubtitle(context),
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.textColor.withOpacity(0.6),
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
decoration: TextDecoration.none,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
// Skip next - nudged right to close gap with power
|
),
|
||||||
Transform.translate(
|
),
|
||||||
offset: const Offset(6, 0),
|
|
||||||
|
// Transport controls - compact sizing to align with mini player
|
||||||
|
// Play/Pause and Next only shown when powered with content
|
||||||
|
if (widget.player.available && widget.player.powered && widget.trackInfo != null) ...[
|
||||||
|
// Play/Pause - slight nudge right
|
||||||
|
// Touch target increased to 44dp for accessibility (icon remains 28)
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(3, 0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.skip_next_rounded,
|
widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
color: textColor,
|
color: widget.textColor,
|
||||||
size: 28,
|
size: 28,
|
||||||
),
|
),
|
||||||
onPressed: onSkipNext,
|
onPressed: widget.onPlayPause,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
// Power button - smallest
|
// Skip next - nudged right to close gap with power
|
||||||
if (player.available)
|
Transform.translate(
|
||||||
IconButton(
|
offset: const Offset(6, 0),
|
||||||
|
child: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.power_settings_new_rounded,
|
Icons.skip_next_rounded,
|
||||||
color: player.powered ? textColor : textColor.withOpacity(0.5),
|
color: widget.textColor,
|
||||||
size: 20,
|
size: 28,
|
||||||
),
|
),
|
||||||
onPressed: onPower,
|
onPressed: widget.onSkipNext,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(),
|
constraints: const BoxConstraints(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
],
|
||||||
),
|
// Power button - smallest
|
||||||
|
if (widget.player.available)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.power_settings_new_rounded,
|
||||||
|
color: widget.player.powered ? widget.textColor : widget.textColor.withOpacity(0.5),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: widget.onPower,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onDragStart(DragStartDetails details) {
|
||||||
|
// Get current volume from player
|
||||||
|
final currentVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
|
||||||
|
setState(() {
|
||||||
|
_isDraggingVolume = true;
|
||||||
|
_dragVolumeLevel = currentVolume;
|
||||||
|
});
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragUpdate(DragUpdateDetails details) {
|
||||||
|
if (!_isDraggingVolume || _cardWidth <= 0) return;
|
||||||
|
|
||||||
|
// Directional volume: drag right = increase, drag left = decrease
|
||||||
|
// Distance determines amount of change (full card width = 100%)
|
||||||
|
// Multiple swipes accumulate - each starts from current volume
|
||||||
|
final dragDelta = details.delta.dx;
|
||||||
|
final volumeDelta = dragDelta / _cardWidth;
|
||||||
|
|
||||||
|
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
// Always update visual
|
||||||
|
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
|
||||||
|
setState(() {
|
||||||
|
_dragVolumeLevel = newVolume;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Throttle API calls to prevent flooding
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (now - _lastVolumeUpdateTime >= _volumeThrottleMs) {
|
||||||
|
_lastVolumeUpdateTime = now;
|
||||||
|
widget.onVolumeChange?.call(newVolume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragEnd(DragEndDetails details) {
|
||||||
|
// Send final volume on release
|
||||||
|
widget.onVolumeChange?.call(_dragVolumeLevel);
|
||||||
|
setState(() {
|
||||||
|
_isDraggingVolume = false;
|
||||||
|
});
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragCancel() {
|
||||||
|
setState(() {
|
||||||
|
_isDraggingVolume = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSpeakerIcon() {
|
Widget _buildSpeakerIcon() {
|
||||||
// Darker shade of the card background to match album art square
|
// Darker shade of the card background to match album art square
|
||||||
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
final iconBgColor = Color.lerp(widget.backgroundColor, Colors.black, 0.15)!;
|
||||||
return Container(
|
return Container(
|
||||||
color: iconBgColor,
|
color: iconBgColor,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.speaker_rounded,
|
Icons.speaker_rounded,
|
||||||
color: textColor.withOpacity(0.4),
|
color: widget.textColor.withOpacity(0.4),
|
||||||
size: 28,
|
size: 28,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -214,30 +348,30 @@ class PlayerCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Color _getStatusColor() {
|
Color _getStatusColor() {
|
||||||
if (!player.available) {
|
if (!widget.player.available) {
|
||||||
return Colors.grey.withOpacity(0.5);
|
return Colors.grey.withOpacity(0.5);
|
||||||
}
|
}
|
||||||
if (!player.powered) {
|
if (!widget.player.powered) {
|
||||||
return Colors.grey;
|
return Colors.grey;
|
||||||
}
|
}
|
||||||
if (isPlaying) {
|
if (widget.isPlaying) {
|
||||||
return Colors.green;
|
return Colors.green;
|
||||||
}
|
}
|
||||||
if (trackInfo != null) {
|
if (widget.trackInfo != null) {
|
||||||
return Colors.orange; // Has content but paused
|
return Colors.orange; // Has content but paused
|
||||||
}
|
}
|
||||||
return Colors.grey.shade400; // Idle
|
return Colors.grey.shade400; // Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getSubtitle(BuildContext context) {
|
String _getSubtitle(BuildContext context) {
|
||||||
if (!player.available) {
|
if (!widget.player.available) {
|
||||||
return S.of(context)!.playerStateUnavailable;
|
return S.of(context)!.playerStateUnavailable;
|
||||||
}
|
}
|
||||||
if (!player.powered) {
|
if (!widget.player.powered) {
|
||||||
return S.of(context)!.playerStateOff;
|
return S.of(context)!.playerStateOff;
|
||||||
}
|
}
|
||||||
if (trackInfo != null) {
|
if (widget.trackInfo != null) {
|
||||||
return trackInfo.name ?? S.of(context)!.playerStateIdle;
|
return widget.trackInfo.name ?? S.of(context)!.playerStateIdle;
|
||||||
}
|
}
|
||||||
return S.of(context)!.playerStateIdle;
|
return S.of(context)!.playerStateIdle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,19 +59,25 @@ class PlayerControls extends StatelessWidget {
|
|||||||
if (isExpanded && expandedElementsOpacity > 0.1)
|
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||||
_buildSecondaryButton(
|
_buildSecondaryButton(
|
||||||
icon: Icons.shuffle_rounded,
|
icon: Icons.shuffle_rounded,
|
||||||
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
// Fix: compute final opacity once to avoid multiplicative opacity bug
|
||||||
.withOpacity(expandedElementsOpacity),
|
color: shuffle == true
|
||||||
|
? primaryColor.withOpacity(expandedElementsOpacity)
|
||||||
|
: textColor.withOpacity(0.5 * expandedElementsOpacity),
|
||||||
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
||||||
),
|
),
|
||||||
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
|
||||||
// Previous
|
// Previous
|
||||||
_buildControlButton(
|
Semantics(
|
||||||
icon: Icons.skip_previous_rounded,
|
button: true,
|
||||||
color: textColor,
|
label: 'Previous track',
|
||||||
size: skipButtonSize,
|
child: _buildControlButton(
|
||||||
onPressed: onPrevious,
|
icon: Icons.skip_previous_rounded,
|
||||||
useAnimation: isExpanded,
|
color: textColor,
|
||||||
|
size: skipButtonSize,
|
||||||
|
onPressed: onPrevious,
|
||||||
|
useAnimation: isExpanded,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: _lerpDouble(0, 20, t)),
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
|
||||||
@@ -80,12 +86,16 @@ class PlayerControls extends StatelessWidget {
|
|||||||
SizedBox(width: _lerpDouble(0, 20, t)),
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
|
||||||
// Next
|
// Next
|
||||||
_buildControlButton(
|
Semantics(
|
||||||
icon: Icons.skip_next_rounded,
|
button: true,
|
||||||
color: textColor,
|
label: 'Next track',
|
||||||
size: skipButtonSize,
|
child: _buildControlButton(
|
||||||
onPressed: onNext,
|
icon: Icons.skip_next_rounded,
|
||||||
useAnimation: isExpanded,
|
color: textColor,
|
||||||
|
size: skipButtonSize,
|
||||||
|
onPressed: onNext,
|
||||||
|
useAnimation: isExpanded,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Repeat (expanded only)
|
// Repeat (expanded only)
|
||||||
@@ -94,10 +104,10 @@ class PlayerControls extends StatelessWidget {
|
|||||||
if (isExpanded && expandedElementsOpacity > 0.1)
|
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||||
_buildSecondaryButton(
|
_buildSecondaryButton(
|
||||||
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
||||||
color: (repeatMode != null && repeatMode != 'off'
|
// Fix: compute final opacity once to avoid multiplicative opacity bug
|
||||||
? primaryColor
|
color: (repeatMode != null && repeatMode != 'off')
|
||||||
: textColor.withOpacity(0.5))
|
? primaryColor.withOpacity(expandedElementsOpacity)
|
||||||
.withOpacity(expandedElementsOpacity),
|
: textColor.withOpacity(0.5 * expandedElementsOpacity),
|
||||||
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -151,21 +161,26 @@ class PlayerControls extends StatelessWidget {
|
|||||||
final bgColor = Color.lerp(Colors.transparent, primaryColor, progress);
|
final bgColor = Color.lerp(Colors.transparent, primaryColor, progress);
|
||||||
final iconColor = Color.lerp(textColor, backgroundColor, progress);
|
final iconColor = Color.lerp(textColor, backgroundColor, progress);
|
||||||
|
|
||||||
return GestureDetector(
|
return Semantics(
|
||||||
onLongPress: onStop,
|
button: true,
|
||||||
child: Container(
|
label: isPlaying ? 'Pause' : 'Play',
|
||||||
width: playButtonContainerSize,
|
hint: 'Long press to stop',
|
||||||
height: playButtonContainerSize,
|
child: GestureDetector(
|
||||||
decoration: BoxDecoration(
|
onLongPress: onStop,
|
||||||
color: bgColor,
|
child: Container(
|
||||||
shape: BoxShape.circle,
|
width: playButtonContainerSize,
|
||||||
),
|
height: playButtonContainerSize,
|
||||||
child: IconButton(
|
decoration: BoxDecoration(
|
||||||
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
|
color: bgColor,
|
||||||
color: iconColor,
|
shape: BoxShape.circle,
|
||||||
iconSize: playButtonSize,
|
),
|
||||||
onPressed: onPlayPause,
|
child: IconButton(
|
||||||
padding: EdgeInsets.zero,
|
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
|
||||||
|
color: iconColor,
|
||||||
|
iconSize: playButtonSize,
|
||||||
|
onPressed: onPlayPause,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -307,9 +307,28 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
|
|
||||||
// Show different hint for onboarding vs regular use
|
// Show different hint for onboarding vs regular use
|
||||||
final isOnboarding = widget.showOnboardingHints;
|
final isOnboarding = widget.showOnboardingHints;
|
||||||
final hintText = isOnboarding
|
|
||||||
? S.of(context)!.selectPlayerHint
|
Widget buildHintRow(IconData icon, String text) {
|
||||||
: S.of(context)!.holdToSync;
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(0, hintSlideOffset),
|
offset: Offset(0, hintSlideOffset),
|
||||||
@@ -317,25 +336,16 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Material(
|
child: Material(
|
||||||
type: MaterialType.transparency,
|
type: MaterialType.transparency,
|
||||||
child: Row(
|
child: isOnboarding
|
||||||
mainAxisSize: MainAxisSize.min,
|
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
|
||||||
children: [
|
: Column(
|
||||||
Icon(
|
mainAxisSize: MainAxisSize.min,
|
||||||
isOnboarding ? Icons.touch_app_outlined : Icons.lightbulb_outline,
|
children: [
|
||||||
size: 18,
|
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
const SizedBox(height: 4),
|
||||||
),
|
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||||
const SizedBox(width: 6),
|
],
|
||||||
Text(
|
|
||||||
hintText,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -382,7 +392,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
albumArtUrl: albumArtUrl,
|
albumArtUrl: albumArtUrl,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
isGrouped: player.isGrouped,
|
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
|
||||||
backgroundColor: cardBgColor,
|
backgroundColor: cardBgColor,
|
||||||
textColor: cardTextColor,
|
textColor: cardTextColor,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -403,6 +413,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
},
|
},
|
||||||
onSkipNext: () => maProvider.nextTrack(player.playerId),
|
onSkipNext: () => maProvider.nextTrack(player.playerId),
|
||||||
onPower: () => maProvider.togglePower(player.playerId),
|
onPower: () => maProvider.togglePower(player.playerId),
|
||||||
|
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -448,7 +459,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
albumArtUrl: albumArtUrl,
|
albumArtUrl: albumArtUrl,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
isGrouped: player.isGrouped,
|
isGrouped: maProvider.isPlayerManuallySynced(player.playerId),
|
||||||
backgroundColor: cardBgColor,
|
backgroundColor: cardBgColor,
|
||||||
textColor: cardTextColor,
|
textColor: cardTextColor,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -480,6 +491,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
|||||||
onPower: () {
|
onPower: () {
|
||||||
maProvider.togglePower(player.playerId);
|
maProvider.togglePower(player.playerId);
|
||||||
},
|
},
|
||||||
|
onVolumeChange: (volume) => maProvider.setVolume(player.playerId, (volume * 100).round()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,8 +226,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this player is part of a sync group
|
// Check if this player is manually synced (not a pre-configured group)
|
||||||
final isGrouped = player.isGrouped;
|
final isGrouped = maProvider.isPlayerManuallySynced(player.playerId);
|
||||||
// Pastel yellow for grouped players
|
// Pastel yellow for grouped players
|
||||||
const groupBorderColor = Color(0xFFFFF59D);
|
const groupBorderColor = Color(0xFFFFF59D);
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.7.3-beta
|
version: 2.7.4-beta+36
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -28,6 +28,7 @@ dependencies:
|
|||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
|
flutter_secure_storage: ^9.2.2
|
||||||
drift: ^2.22.0
|
drift: ^2.22.0
|
||||||
drift_flutter: 0.2.0
|
drift_flutter: 0.2.0
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|||||||
Reference in New Issue
Block a user