Compare commits
@@ -35,6 +35,16 @@ jobs:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -65,7 +75,12 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
run: |
|
||||
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
|
||||
mkdir -p ~/.gradle
|
||||
cp android/init.gradle ~/.gradle/init.gradle
|
||||
|
||||
flutter build apk --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Player System Animation & Polish Audit
|
||||
|
||||
## Context
|
||||
|
||||
This is a Music Assistant client app (Ensemble) built with Flutter. The player system has undergone extensive development and now needs a comprehensive polish audit. The goal is buttery smooth 60fps animations and a premium user experience.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The player system consists of three interconnected components:
|
||||
|
||||
1. **Mini Player** (collapsed state) - Floating card at bottom, shows current track
|
||||
2. **Expanded Player** - Full-screen player with album art, controls, progress
|
||||
3. **Queue Panel** - Slides in from right of expanded player, shows playback queue
|
||||
|
||||
Additionally:
|
||||
- **Player Selector** - Horizontal swipe between multiple players (collapsed mode)
|
||||
- **Device Reveal** - Swipe down from mini player to show available players
|
||||
|
||||
## Key Files
|
||||
|
||||
```
|
||||
lib/widgets/
|
||||
├── expandable_player.dart # Main player widget (1800+ lines)
|
||||
├── global_player_overlay.dart # Overlay wrapper, static accessors
|
||||
├── player/
|
||||
│ ├── queue_panel.dart # Queue list with great_list_view
|
||||
│ ├── chapters_panel.dart # Audiobook chapters
|
||||
│ └── mini_player_content.dart
|
||||
```
|
||||
|
||||
## CRITICAL CONSTRAINTS
|
||||
|
||||
1. **DO NOT MODIFY the queue list implementation** - It uses `great_list_view` package with `AutomaticAnimatedListView`. This was chosen specifically to avoid Flutter's grey screen overlay bug (#103804). The current implementation works correctly.
|
||||
|
||||
2. **Preserve all existing functionality** - Focus on polish, not rewrites
|
||||
|
||||
3. **Test changes incrementally** - This system is complex with many interdependencies
|
||||
|
||||
## Audit Areas
|
||||
|
||||
### 1. Animation Performance Audit
|
||||
|
||||
Use sub-agents to analyze each animation system:
|
||||
|
||||
**Agent 1: Expand/Collapse Animation**
|
||||
- Analyze `_controller` and `_expandAnimation`
|
||||
- Check for jank during morphing transition
|
||||
- Verify lerp functions are efficient
|
||||
- Look for unnecessary rebuilds during animation
|
||||
- Check `RepaintBoundary` placement
|
||||
|
||||
**Agent 2: Queue Panel Animation**
|
||||
- Analyze `_queuePanelController` and `_queuePanelAnimation`
|
||||
- Verify slide-in/out is smooth
|
||||
- Check interaction with expanded player state
|
||||
- Verify the Listener-based swipe detection doesn't cause issues
|
||||
|
||||
**Agent 3: Player Selector Animation**
|
||||
- Analyze horizontal swipe between players
|
||||
- Check `_slideOffset` and peek player animations
|
||||
- Verify finger-following feels natural
|
||||
- Look for edge cases with rapid swipes
|
||||
|
||||
**Agent 4: Micro-interactions**
|
||||
- Play/pause button animations
|
||||
- Progress bar interactions
|
||||
- Volume swipe overlay
|
||||
- Favorite button feedback
|
||||
- Skip button responsiveness
|
||||
|
||||
### 2. State Management Audit
|
||||
|
||||
**Check for:**
|
||||
- Unnecessary `setState` calls during animations
|
||||
- ValueNotifier usage efficiency
|
||||
- Animation listener cleanup
|
||||
- Memory leaks from subscriptions
|
||||
- State synchronization between components
|
||||
|
||||
### 3. Visual Polish Audit
|
||||
|
||||
**Check for:**
|
||||
- Color transitions during expand/collapse
|
||||
- Text fade/scale animations
|
||||
- Image loading placeholders
|
||||
- Shadow/elevation consistency
|
||||
- Border radius morphing smoothness
|
||||
- Safe area handling
|
||||
|
||||
### 4. Gesture Conflict Resolution
|
||||
|
||||
**Verify:**
|
||||
- Vertical drag (expand/collapse) vs horizontal drag (player switch)
|
||||
- Queue panel swipe-right-to-close vs Dismissible swipe-left-to-delete
|
||||
- Edge dead zones for Android back gesture
|
||||
- Tap vs drag disambiguation
|
||||
|
||||
### 5. Edge Cases
|
||||
|
||||
**Test scenarios:**
|
||||
- Rapid expand/collapse
|
||||
- Swipe during animation
|
||||
- Back button during animations
|
||||
- Track change during queue scroll
|
||||
- Network image loading failures
|
||||
- Very long track/artist names
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
```
|
||||
ultrathink: true
|
||||
```
|
||||
|
||||
### Phase 1: Discovery (Read-Only)
|
||||
Launch parallel sub-agents to analyze each file:
|
||||
- Read and understand the animation architecture
|
||||
- Map all animation controllers and their relationships
|
||||
- Identify potential performance bottlenecks
|
||||
- Document current RepaintBoundary usage
|
||||
|
||||
### Phase 2: Analysis Report
|
||||
Compile findings into categories:
|
||||
- **Critical**: Causes visible jank or bugs
|
||||
- **Important**: Noticeable but not breaking
|
||||
- **Nice-to-have**: Minor polish improvements
|
||||
|
||||
### Phase 3: Implementation Plan
|
||||
For each finding:
|
||||
- Specific file and line numbers
|
||||
- Proposed change
|
||||
- Risk assessment
|
||||
- Testing approach
|
||||
|
||||
### Phase 4: Incremental Fixes
|
||||
- One fix at a time
|
||||
- Build and test after each change
|
||||
- Verify no regressions
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide a structured audit report:
|
||||
|
||||
```markdown
|
||||
## Animation Audit Report
|
||||
|
||||
### Executive Summary
|
||||
[2-3 sentences on overall state]
|
||||
|
||||
### Critical Issues
|
||||
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||
|
||||
### Important Issues
|
||||
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||
|
||||
### Polish Opportunities
|
||||
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||
|
||||
### Architecture Observations
|
||||
[Notes on overall structure, patterns noticed]
|
||||
|
||||
### Recommended Priority Order
|
||||
1. [First fix]
|
||||
2. [Second fix]
|
||||
...
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
After this audit and fixes:
|
||||
- All animations run at consistent 60fps
|
||||
- No jank during any transition
|
||||
- Gestures feel responsive and natural
|
||||
- State transitions are seamless
|
||||
- No visual glitches or flickers
|
||||
- Memory usage is stable during interactions
|
||||
|
||||
## Notes
|
||||
|
||||
- The app uses adaptive theming (colors extracted from album art)
|
||||
- There's an `AnimationDebugger` utility for profiling
|
||||
- Position tracking uses a shared `PositionTracker` singleton
|
||||
- The player supports both music and audiobooks (different UI modes)
|
||||
@@ -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**
|
||||
@@ -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.*
|
||||
@@ -0,0 +1,119 @@
|
||||
# Ensemble Codebase Audit Prompt
|
||||
|
||||
Run this prompt with Claude Code to perform a comprehensive audit of the Ensemble Flutter app.
|
||||
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
You are performing a comprehensive code audit on the Ensemble Flutter app. This is a music/audiobook/podcast player app with 93+ Dart files.
|
||||
|
||||
**CRITICAL RULES:**
|
||||
1. DO NOT remove or break any existing functionality
|
||||
2. All changes must be on the current branch: `audit/code-quality-review`
|
||||
3. Run `flutter analyze` after each set of changes to verify no regressions
|
||||
4. Commit changes in logical, reviewable chunks
|
||||
|
||||
**AUDIT PHASES:**
|
||||
|
||||
### Phase 1: Code Quality Analysis (Read-Only)
|
||||
First, analyze the codebase without making changes. Create a report covering:
|
||||
|
||||
1. **Large File Analysis** - Review these files for refactoring opportunities:
|
||||
- `lib/screens/new_library_screen.dart` (122KB)
|
||||
- `lib/screens/search_screen.dart` (129KB)
|
||||
- `lib/widgets/expandable_player.dart` (107KB)
|
||||
- `lib/screens/album_details_screen.dart` (48KB)
|
||||
- `lib/screens/artist_details_screen.dart` (45KB)
|
||||
|
||||
2. **Pattern Inconsistencies** - Look for:
|
||||
- Inconsistent naming conventions (camelCase vs snake_case)
|
||||
- Mixed widget composition patterns
|
||||
- Inconsistent error handling approaches
|
||||
- Duplicate code across screens/widgets
|
||||
|
||||
3. **Performance Red Flags** - Identify:
|
||||
- Unnecessary rebuilds (missing const constructors)
|
||||
- Heavy computations in build methods
|
||||
- Missing list item caching (ListView.builder vs ListView)
|
||||
- Inefficient state management patterns
|
||||
|
||||
4. **Scroll Performance Issues** - Specifically audit:
|
||||
- ScrollController usage and disposal
|
||||
- Sliver implementations
|
||||
- AnimatedBuilder/AnimatedWidget usage
|
||||
- Image loading in scrollable lists
|
||||
- Any jank-inducing patterns
|
||||
|
||||
### Phase 2: Quick Wins (Low Risk, High Impact)
|
||||
Apply these fixes first:
|
||||
|
||||
1. Add missing `const` constructors throughout
|
||||
2. Fix any `flutter analyze` warnings
|
||||
3. Remove dead code and unused imports
|
||||
4. Standardize formatting with `dart format`
|
||||
|
||||
### Phase 3: Scroll Animation Optimization
|
||||
Focus on smoother scrolling:
|
||||
|
||||
1. **Image Optimization**
|
||||
- Ensure CachedNetworkImage is used consistently
|
||||
- Add appropriate cacheWidth/cacheHeight
|
||||
- Implement proper placeholder/error widgets
|
||||
|
||||
2. **List Performance**
|
||||
- Convert any ListView to ListView.builder where appropriate
|
||||
- Add itemExtent where possible for fixed-height items
|
||||
- Implement AutomaticKeepAliveClientMixin where needed
|
||||
- Consider RepaintBoundary for complex list items
|
||||
|
||||
3. **Animation Smoothing**
|
||||
- Use `Curves.easeOutCubic` or similar for scroll-linked animations
|
||||
- Ensure animations run at 60fps (avoid heavy computations during animation)
|
||||
- Review CustomScrollView/Sliver implementations
|
||||
|
||||
### Phase 4: Code Consistency Improvements
|
||||
|
||||
1. **Widget Extraction**
|
||||
- Extract repeated UI patterns into reusable widgets
|
||||
- Ensure extracted widgets follow existing naming patterns
|
||||
|
||||
2. **State Management**
|
||||
- Verify Provider usage is consistent
|
||||
- Check for proper disposal of controllers/streams
|
||||
|
||||
3. **Error Handling**
|
||||
- Standardize try/catch patterns
|
||||
- Ensure consistent loading/error state UI
|
||||
|
||||
### Phase 5: Verification
|
||||
|
||||
After all changes:
|
||||
1. Run `flutter analyze` - must pass with 0 issues
|
||||
2. Run `flutter test` if tests exist
|
||||
3. Create a summary of all changes made
|
||||
4. List any recommendations that were NOT implemented (too risky)
|
||||
|
||||
---
|
||||
|
||||
## Execution Instructions
|
||||
|
||||
```bash
|
||||
# Start the audit
|
||||
cd /home/home-server/Ensemble
|
||||
git checkout audit/code-quality-review
|
||||
|
||||
# After each phase, commit your changes
|
||||
git add -A && git commit -m "Audit Phase X: [description]"
|
||||
|
||||
# Verify no regressions
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Deliverables
|
||||
|
||||
1. `AUDIT_REPORT.md` - Findings from Phase 1
|
||||
2. Multiple commits with clear messages for each phase
|
||||
3. `AUDIT_SUMMARY.md` - Final summary of all changes and remaining recommendations
|
||||
@@ -0,0 +1,136 @@
|
||||
# Ensemble Flutter App - Code Audit Report
|
||||
|
||||
**Date:** January 4, 2026
|
||||
**Branch:** `audit/code-quality-review`
|
||||
**Files Audited:** 93 Dart files
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The codebase is generally well-maintained with good existing performance optimizations (RepaintBoundary, ListView.builder, cacheExtent settings). Key improvement areas identified:
|
||||
|
||||
| Category | Issues Found | Priority |
|
||||
|----------|-------------|----------|
|
||||
| Deprecated APIs | 20 instances | HIGH |
|
||||
| Missing const constructors | 100+ instances | MEDIUM |
|
||||
| Image cache parameters | 7 locations | MEDIUM |
|
||||
| Code duplication | 8 view mode functions | LOW |
|
||||
| Color constants | 3 files with duplicates | LOW |
|
||||
|
||||
---
|
||||
|
||||
## 1. CRITICAL: Deprecated API Usage
|
||||
|
||||
### `colorScheme.background` (20 instances)
|
||||
Flutter Material 3 deprecates `ColorScheme.background`. Replace with `colorScheme.surface`.
|
||||
|
||||
**Files affected:**
|
||||
- lib/main.dart:335, 336
|
||||
- lib/screens/library_artists_screen.dart:21
|
||||
- lib/screens/new_home_screen.dart:121
|
||||
- lib/screens/artist_details_screen.dart:708, 720
|
||||
- lib/screens/library_playlists_screen.dart:53
|
||||
- lib/screens/library_tracks_screen.dart:19
|
||||
- lib/screens/login_screen.dart:489
|
||||
- lib/screens/new_library_screen.dart:933, 957, 1020, 1021
|
||||
- lib/screens/album_details_screen.dart:807, 819
|
||||
- lib/screens/library_albums_screen.dart:19
|
||||
- lib/screens/search_screen.dart:427, 738, 739
|
||||
- lib/screens/settings_screen.dart:245
|
||||
|
||||
---
|
||||
|
||||
## 2. HIGH: Missing const Constructors
|
||||
|
||||
### EdgeInsets.zero (64 instances)
|
||||
High-impact optimization - used extensively in button padding.
|
||||
|
||||
**Key files:**
|
||||
- lib/screens/album_details_screen.dart (12 instances)
|
||||
- lib/screens/artist_details_screen.dart (8 instances)
|
||||
- lib/screens/audiobook_detail_screen.dart (3 instances)
|
||||
- lib/screens/audiobook_series_screen.dart (2 instances)
|
||||
- lib/screens/audiobook_author_screen.dart (2 instances)
|
||||
|
||||
### BorderRadius.circular (40+ instances)
|
||||
Cannot use `const BorderRadius.circular()` directly. Alternative: `const BorderRadius.all(Radius.circular(X))`.
|
||||
|
||||
---
|
||||
|
||||
## 3. MEDIUM: Scroll Performance - Missing Image Cache Parameters
|
||||
|
||||
### CachedNetworkImage without memCacheWidth/Height
|
||||
|
||||
**lib/screens/new_library_screen.dart:**
|
||||
- Line 1395-1402: Author image (48x48) - missing cache params
|
||||
- Line 1480-1486: Author card image - missing cache params
|
||||
- Line 1572-1580: Audiobook cover (56x56) - missing cache params
|
||||
- Line 1740-1745: Audiobook grid cover - missing cache params
|
||||
- Line 1959-1969: Playlist cover (56x56) - missing cache params
|
||||
- Line 2143-2152: Playlist grid covers - missing cache params
|
||||
- Line 2973-2981: Album cover (56x56) - missing cache params
|
||||
|
||||
**Note:** DecorationImage with CachedNetworkImageProvider cannot have cache size specified (structural Flutter limitation).
|
||||
|
||||
---
|
||||
|
||||
## 4. LOW: Code Duplication
|
||||
|
||||
### View Mode Cycling Functions (8 nearly identical functions)
|
||||
**File:** lib/screens/new_library_screen.dart
|
||||
|
||||
Functions that follow identical switch/case pattern:
|
||||
- `_cycleArtistsViewMode()` (line 184)
|
||||
- `_cycleAlbumsViewMode()` (line 200)
|
||||
- `_cyclePlaylistsViewMode()` (line 216)
|
||||
- `_cycleAuthorsViewMode()` (line 232)
|
||||
- `_cycleAudiobooksViewMode()` (line 248)
|
||||
- `_cycleSeriesViewMode()` (line 286)
|
||||
- `_cycleRadioViewMode()` (line 303)
|
||||
- `_cyclePodcastsViewMode()` (line 319)
|
||||
|
||||
**Recommendation:** Create generic utility function.
|
||||
|
||||
### Duplicated Color Constants (3 files)
|
||||
Series fallback colors defined identically in:
|
||||
- lib/widgets/series_row.dart:246-253
|
||||
- lib/screens/audiobook_series_screen.dart:466-473
|
||||
- lib/screens/new_library_screen.dart:2112-2119
|
||||
|
||||
**Recommendation:** Extract to lib/constants/colors.dart
|
||||
|
||||
---
|
||||
|
||||
## 5. POSITIVE FINDINGS (Already Optimized)
|
||||
|
||||
- **RepaintBoundary**: Properly implemented in all list item widgets
|
||||
- **ListView.builder**: Used consistently with cacheExtent: 1000
|
||||
- **ScrollController disposal**: All 8+ controllers properly disposed
|
||||
- **Animation patterns**: Proper listener cleanup in expandable_player.dart
|
||||
- **Debounced operations**: Color extraction debounced to 300ms
|
||||
|
||||
---
|
||||
|
||||
## Recommended Action Plan
|
||||
|
||||
### Phase 1: Quick Wins (Low Risk)
|
||||
1. Replace `colorScheme.background` with `colorScheme.surface` (20 locations)
|
||||
2. Add `const` to EdgeInsets.zero instances
|
||||
|
||||
### Phase 2: Image Optimization
|
||||
1. Add memCacheWidth/memCacheHeight to CachedNetworkImage instances in new_library_screen.dart
|
||||
|
||||
### Phase 3: Code Cleanup (Optional)
|
||||
1. Extract view mode cycling to utility function
|
||||
2. Centralize color constants
|
||||
|
||||
---
|
||||
|
||||
## Files by Complexity
|
||||
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| lib/screens/search_screen.dart | 3,489 | Good structure, minor fixes needed |
|
||||
| lib/screens/new_library_screen.dart | 3,351 | Good performance, some duplication |
|
||||
| lib/widgets/expandable_player.dart | 2,577 | Well-optimized animations |
|
||||
@@ -0,0 +1,57 @@
|
||||
# Audit Summary
|
||||
|
||||
**Branch:** `audit/code-quality-review`
|
||||
**Date:** January 4, 2026
|
||||
|
||||
## Changes Applied
|
||||
|
||||
### Phase 1: Analysis Complete
|
||||
- Created `AUDIT_REPORT.md` with comprehensive findings
|
||||
- 4 parallel subagents analyzed const constructors, scroll performance, code duplication, and dead code
|
||||
|
||||
### Phase 2: Deprecated API Fixes (18 files)
|
||||
- Replaced all `colorScheme.background` with `colorScheme.surface` (Material 3 compliance)
|
||||
|
||||
**Files modified:**
|
||||
- lib/screens/library_artists_screen.dart
|
||||
- lib/screens/library_albums_screen.dart
|
||||
- lib/screens/library_playlists_screen.dart
|
||||
- lib/screens/library_tracks_screen.dart
|
||||
- lib/screens/artist_details_screen.dart
|
||||
- lib/screens/new_home_screen.dart
|
||||
- lib/screens/login_screen.dart
|
||||
- lib/screens/settings_screen.dart
|
||||
- lib/screens/album_details_screen.dart
|
||||
- lib/screens/search_screen.dart
|
||||
- lib/screens/new_library_screen.dart
|
||||
|
||||
### Phase 3: Scroll Performance Optimization
|
||||
- Added `memCacheWidth` and `memCacheHeight` to CachedNetworkImage instances
|
||||
|
||||
**lib/screens/new_library_screen.dart:**
|
||||
- Line 1400-1401: Author list tile image (128x128 cache)
|
||||
- Line 1487-1488: Author grid card image (256x256 cache)
|
||||
- Line 1577-1578: Audiobook list tile cover (256x256 cache)
|
||||
- Line 1749-1750: Audiobook grid cover (512x512 cache)
|
||||
- Line 1970-1971: Series list tile cover (256x256 cache)
|
||||
- Line 2986-2987: Album list tile cover (256x256 cache)
|
||||
|
||||
## Recommendations NOT Implemented (Low Priority)
|
||||
|
||||
These were identified but NOT changed to preserve functionality:
|
||||
|
||||
1. **View mode cycling refactor** - 8 similar functions could be consolidated
|
||||
2. **Color constant extraction** - Duplicated colors in 3 files
|
||||
3. **EdgeInsets.zero const** - 64 instances (build-time only, minimal impact)
|
||||
4. **BorderRadius.circular const** - 40+ instances
|
||||
|
||||
## Verification
|
||||
|
||||
Changes should be verified by:
|
||||
1. GitHub Actions build (triggered automatically)
|
||||
2. Manual UI testing of scroll smoothness
|
||||
3. Verify no visual regressions in dark/light themes
|
||||
|
||||
## Build Trigger
|
||||
|
||||
GitHub Actions workflow triggered on branch `audit/code-quality-review`
|
||||
@@ -20,44 +20,63 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
## Features
|
||||
|
||||
### Local Playback
|
||||
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device
|
||||
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device via Sendspin protocol
|
||||
- **Background Playback** - Music continues playing when the app is minimized
|
||||
- **Media Notifications** - Control playback from your notification shade with album art display
|
||||
- **Instant Response** - Pause/resume in ~300ms
|
||||
|
||||
### Remote Control
|
||||
- **Multi-Player Support** - Control any speaker or device connected to Music Assistant
|
||||
- **Device Selector** - Quickly switch between your phone and other players
|
||||
- **Device Selector** - Swipe down on mini player to reveal all your devices
|
||||
- **Multi-Room Grouping** - Long-press any player to sync it with the current player
|
||||
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
|
||||
- **Queue Management** - View and manage the playback queue
|
||||
- **Volume Precision Mode** - Hold the volume slider for fine-grained control with haptic feedback
|
||||
- **Power Control** - Turn players on/off directly from the mini player
|
||||
|
||||
### Queue Management
|
||||
- **View & Manage Queue** - See upcoming tracks in the playback queue
|
||||
- **Drag to Reorder** - Instant drag handles for reordering tracks
|
||||
- **Swipe to Delete** - Remove tracks with a simple swipe gesture
|
||||
|
||||
### Home Screen
|
||||
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
|
||||
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
|
||||
- **Customizable Rows** - Toggle and reorder: Recently Played, Discover Artists, Discover Albums
|
||||
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, Tracks, Playlists, and Radio Stations
|
||||
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
|
||||
- **Pull to Refresh** - Refresh content with a simple pull gesture
|
||||
|
||||
### Library Browsing
|
||||
- **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
|
||||
### Library
|
||||
- **Music** - Browse artists, albums, playlists, and tracks from all your music sources
|
||||
- **Radio Stations** - Browse and play radio stations with list or grid view
|
||||
- **Podcasts** - Browse podcasts, view episodes with descriptions and publish dates
|
||||
- **Audiobooks** - Browse by title, series, or author with progress tracking
|
||||
- **Favorites Filter** - Toggle to show only your favorite items
|
||||
- **Album Details** - View track listings with artwork
|
||||
- **Artist Details** - View artist albums and top tracks
|
||||
- **Playlist Support** - Browse and play your playlists
|
||||
- **Search** - Find music across your entire library
|
||||
- **Letter Scrollbar** - Fast navigation through long lists
|
||||
|
||||
### Search
|
||||
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
|
||||
- **Fuzzy Matching** - Typo-tolerant search (e.g., "beetles" finds "Beatles")
|
||||
- **Smart Scoring** - Results ranked by relevance with colored type indicators
|
||||
- **Search History** - Quickly access your recent searches
|
||||
- **Quick Actions** - Long-press any result to add to queue or play next
|
||||
|
||||
### Audiobooks
|
||||
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
|
||||
- **Series Support** - View audiobooks organized by series with collage cover art
|
||||
- **Author Browsing** - Browse audiobooks by author
|
||||
- **Chapter Navigation** - Jump between chapters with timestamp display
|
||||
- **Progress Tracking** - Track your listening progress across sessions
|
||||
- **Continue Listening** - Pick up where you left off
|
||||
- **Mark as Finished/Unplayed** - Manage your reading progress
|
||||
- **Series Support** - View audiobooks organized by series with collage cover art
|
||||
|
||||
### Podcasts
|
||||
- **Episode Browser** - View full episode list with artwork and descriptions
|
||||
- **Skip Controls** - Skip forward/backward during playback
|
||||
- **High-Resolution Artwork** - Fetched via iTunes for best quality
|
||||
|
||||
### Smart Features
|
||||
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
||||
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
||||
- **Offline Browsing** - Browse your cached library even when disconnected
|
||||
- **Hero Animations** - Smooth transitions between screens
|
||||
- **Welcome Screen** - Guided onboarding for first-time users
|
||||
|
||||
### Theming
|
||||
- **Material You** - Dynamic theming based on your device's wallpaper
|
||||
@@ -67,14 +86,16 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
|
||||
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
|
||||
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
|
||||
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
|
||||
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
|
||||
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
|
||||
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
|
||||
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
|
||||
<img src="assets/screenshots/1.png?v=3" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=3" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=3" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=3" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=3" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=3" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=3" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=3" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=3" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=3" alt="Screenshot 10" width="150">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
@@ -84,9 +105,29 @@ Download the latest release from the [Releases page](https://github.com/CollotsS
|
||||
## Setup
|
||||
|
||||
1. Launch the app
|
||||
2. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
|
||||
2. Enter your Music Assistant server URL
|
||||
3. Connect to your server
|
||||
4. Start playing! Music plays on your phone by default, or tap the device icon to choose a different player.
|
||||
4. Start playing! Music plays on your phone by default, or swipe down on the mini player to choose a different player.
|
||||
|
||||
### Finding Your Server URL
|
||||
|
||||
**Important:** You need the **Music Assistant** URL, not your Home Assistant URL.
|
||||
|
||||
To find the correct URL:
|
||||
1. Open Music Assistant web UI
|
||||
2. Go to **Settings** > **About**
|
||||
3. Look for **Base URL** (e.g., `http://192.168.1.100:8095`)
|
||||
|
||||
### Home Assistant Add-on Users
|
||||
|
||||
If you run Music Assistant as a Home Assistant add-on:
|
||||
- Use the IP address of your Home Assistant server
|
||||
- Enter `8095` in the port field
|
||||
- Do **not** use your Home Assistant URL or ingress URL
|
||||
|
||||
### Remote Access
|
||||
|
||||
For access outside your home network, you'll need to expose Music Assistant through a reverse proxy (e.g., Nginx Proxy Manager, Cloudflare Tunnel).
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -103,7 +144,7 @@ Ensemble supports multiple authentication methods:
|
||||
|
||||
## Requirements
|
||||
|
||||
- Music Assistant server (v2.7.0 beta 20 or later)
|
||||
- Music Assistant server (v2.7.0 beta 20 or later recommended)
|
||||
- Network connectivity to your Music Assistant server
|
||||
- Android device (Android 5.0+)
|
||||
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
||||
|
||||
@@ -2,6 +2,9 @@ buildscript {
|
||||
ext.kotlin_version = '1.9.10'
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
@@ -14,6 +17,9 @@ buildscript {
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Force all repositories to use mirrors first
|
||||
allprojects {
|
||||
repositories {
|
||||
all { ArtifactRepository repo ->
|
||||
if (repo instanceof MavenArtifactRepository) {
|
||||
def url = repo.url.toString()
|
||||
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
|
||||
remove repo
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add mirrors before Maven Central
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
}
|
||||
}
|
||||
|
||||
settingsEvaluated { settings ->
|
||||
settings.pluginManagement {
|
||||
repositories {
|
||||
all { ArtifactRepository repo ->
|
||||
if (repo instanceof MavenArtifactRepository) {
|
||||
def url = repo.url.toString()
|
||||
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
|
||||
remove repo
|
||||
}
|
||||
}
|
||||
}
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 649 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 502 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 498 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 478 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -25,4 +25,19 @@ class HeroTags {
|
||||
|
||||
/// Audiobook author image hero tag prefix
|
||||
static const String authorImage = 'author_image_';
|
||||
|
||||
/// Podcast cover hero tag prefix
|
||||
static const String podcastCover = 'podcast_cover_';
|
||||
|
||||
/// Playlist cover hero tag prefix
|
||||
static const String playlistCover = 'playlist_cover_';
|
||||
|
||||
/// Playlist title hero tag prefix
|
||||
static const String playlistTitle = 'playlist_title_';
|
||||
|
||||
/// Radio station cover hero tag prefix
|
||||
static const String radioCover = 'radio_cover_';
|
||||
|
||||
/// Radio station title hero tag prefix
|
||||
static const String radioTitle = 'radio_title_';
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ class Profiles extends Table {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Auto-incrementing ID
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
@@ -55,6 +57,8 @@ class RecentlyPlayed extends Table {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Composite key: provider + item_id
|
||||
TextColumn get cacheKey => text()();
|
||||
@@ -139,6 +143,8 @@ class CachedPlayers extends Table {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Auto-incrementing ID for ordering
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
@@ -185,7 +191,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 4;
|
||||
int get schemaVersion => 5;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -208,6 +214,18 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 4) {
|
||||
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)');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@
|
||||
"tracks": "Songs",
|
||||
"playlists": "Wiedergabelisten",
|
||||
"audiobooks": "Hörbücher",
|
||||
"music": "Musik",
|
||||
"radio": "Radio",
|
||||
"stations": "Sender",
|
||||
"selectLibrary": "Bibliothek auswählen",
|
||||
"noRadioStations": "Keine Radiosender",
|
||||
"addRadioStationsHint": "Füge Radiosender in Music Assistant hinzu",
|
||||
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
|
||||
|
||||
"queue": "Warteschlange",
|
||||
@@ -287,5 +293,6 @@
|
||||
"showHints": "Hinweise anzeigen",
|
||||
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -251,6 +251,16 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoritePlaylists": "Favorite Playlists",
|
||||
"@favoritePlaylists": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoriteRadioStations": "Favorite Radio Stations",
|
||||
"@favoriteRadioStations": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"searchMusic": "Search music...",
|
||||
"@searchMusic": {
|
||||
"description": "Search placeholder text"
|
||||
@@ -316,6 +326,36 @@
|
||||
"description": "Audiobooks category"
|
||||
},
|
||||
|
||||
"music": "Music",
|
||||
"@music": {
|
||||
"description": "Music category"
|
||||
},
|
||||
|
||||
"radio": "Radio",
|
||||
"@radio": {
|
||||
"description": "Radio category"
|
||||
},
|
||||
|
||||
"stations": "Stations",
|
||||
"@stations": {
|
||||
"description": "Radio stations subcategory"
|
||||
},
|
||||
|
||||
"selectLibrary": "Select Library",
|
||||
"@selectLibrary": {
|
||||
"description": "Title for library type selection bottom sheet"
|
||||
},
|
||||
|
||||
"noRadioStations": "No radio stations",
|
||||
"@noRadioStations": {
|
||||
"description": "Empty state when no radio stations are available"
|
||||
},
|
||||
|
||||
"addRadioStationsHint": "Add radio stations in Music Assistant",
|
||||
"@addRadioStationsHint": {
|
||||
"description": "Hint for adding radio stations"
|
||||
},
|
||||
|
||||
"searchFailed": "Search failed. Please check your connection.",
|
||||
"@searchFailed": {
|
||||
"description": "Search error message"
|
||||
@@ -630,6 +670,16 @@
|
||||
"description": "Radio button label"
|
||||
},
|
||||
|
||||
"playingRadioStation": "Playing {name}",
|
||||
"@playingRadioStation": {
|
||||
"description": "Status when playing radio station",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"addedToQueue": "Added to queue",
|
||||
"@addedToQueue": {
|
||||
"description": "Confirmation when item added to queue"
|
||||
@@ -1022,10 +1072,15 @@
|
||||
"showFavoriteAlbums": "Show a row of your favorite albums",
|
||||
"showFavoriteArtists": "Show a row of your favorite artists",
|
||||
"showFavoriteTracks": "Show a row of your favorite tracks",
|
||||
"showFavoritePlaylists": "Show a row of your favorite playlists",
|
||||
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
|
||||
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
|
||||
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
||||
"addedToFavorites": "Added to favorites",
|
||||
"removedFromFavorites": "Removed from favorites",
|
||||
"addedToLibrary": "Added to library",
|
||||
"removedFromLibrary": "Removed from library",
|
||||
"addToLibrary": "Add to library",
|
||||
"unknown": "Unknown",
|
||||
"noUpcomingTracks": "No upcoming tracks",
|
||||
"showAll": "Show all",
|
||||
@@ -1036,6 +1091,10 @@
|
||||
"shows": "Shows",
|
||||
"podcasts": "Podcasts",
|
||||
"podcastSupportComingSoon": "Podcast support coming soon",
|
||||
"noPodcasts": "No podcasts",
|
||||
"addPodcastsHint": "Subscribe to podcasts in Music Assistant",
|
||||
"episodes": "Episodes",
|
||||
"episode": "Episode",
|
||||
"playlist": "Playlist",
|
||||
"connectionError": "Connection Error",
|
||||
"twoColumnGrid": "2-column grid",
|
||||
@@ -1168,6 +1227,11 @@
|
||||
"description": "Singular form of track for search type indicator"
|
||||
},
|
||||
|
||||
"podcastSingular": "Podcast",
|
||||
"@podcastSingular": {
|
||||
"description": "Singular form of podcast for search type indicator"
|
||||
},
|
||||
|
||||
"trackCount": "{count} {count, plural, =1{track} other{tracks}}",
|
||||
"@trackCount": {
|
||||
"description": "Track count with plural",
|
||||
@@ -1273,11 +1337,16 @@
|
||||
"description": "Hint shown when mini player bounces"
|
||||
},
|
||||
|
||||
"holdToSync": "Tap and hold a player to sync",
|
||||
"holdToSync": "Long-press to sync",
|
||||
"@holdToSync": {
|
||||
"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": {
|
||||
"description": "First-time hint for selecting a player in device selector"
|
||||
@@ -1301,5 +1370,25 @@
|
||||
"dismissPlayerHint": "Swipe down, tap outside, or press back to return",
|
||||
"@dismissPlayerHint": {
|
||||
"description": "Hint explaining how to dismiss the player selector"
|
||||
},
|
||||
|
||||
"playingAlbum": "Playing {albumName}",
|
||||
"@playingAlbum": {
|
||||
"description": "Message shown when starting to play an album",
|
||||
"placeholders": {
|
||||
"albumName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"playingPlaylist": "Playing {playlistName}",
|
||||
"@playingPlaylist": {
|
||||
"description": "Message shown when starting to play a playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,12 @@
|
||||
"tracks": "Canciones",
|
||||
"playlists": "Listas de reproducción",
|
||||
"audiobooks": "Audiolibros",
|
||||
"music": "Música",
|
||||
"radio": "Radio",
|
||||
"stations": "Emisoras",
|
||||
"selectLibrary": "Seleccionar Biblioteca",
|
||||
"noRadioStations": "No hay emisoras de radio",
|
||||
"addRadioStationsHint": "Añade emisoras de radio en Music Assistant",
|
||||
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
|
||||
"queue": "Cola",
|
||||
"playerQueue": "Cola de {playerName}",
|
||||
@@ -483,5 +489,6 @@
|
||||
"showHints": "Mostrar Sugerencias",
|
||||
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
||||
"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)
|
||||
await ProfileService.instance.migrateFromOwnerName();
|
||||
|
||||
// Migrate credentials to secure storage (one-time for existing users)
|
||||
await SettingsService.migrateToSecureStorage();
|
||||
_logger.log('🔐 Secure storage migration complete');
|
||||
|
||||
// Load library from cache for instant startup
|
||||
await SyncService.instance.loadFromCache();
|
||||
_logger.log('📦 Library cache loaded');
|
||||
@@ -67,13 +71,16 @@ Future<void> main() async {
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
// Set system UI overlay style
|
||||
// Set initial system UI overlay style based on platform brightness
|
||||
// SystemUIWrapper will update this dynamically when theme changes
|
||||
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
final isDark = platformBrightness == Brightness.dark;
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Color(0xFF1a1a1a),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -172,7 +179,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
@override
|
||||
Future<bool> didPopRoute() async {
|
||||
// Intercept back button at app level - runs BEFORE Navigator processes it
|
||||
// Priority order: device list > expanded player > normal navigation
|
||||
// Priority order: device list > queue panel > expanded player > normal navigation
|
||||
|
||||
// Device list has highest priority - dismiss it first
|
||||
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
||||
@@ -180,6 +187,14 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
// Queue panel second - close it before collapsing player
|
||||
// Use target state (not animation value) to handle rapid open-close timing
|
||||
// Use withHaptic: false because Android back gesture provides system haptic
|
||||
if (GlobalPlayerOverlay.isQueuePanelTargetOpen) {
|
||||
GlobalPlayerOverlay.closeQueuePanel(withHaptic: false);
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
// Then check expanded player
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
@@ -225,10 +240,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
ColorScheme? darkColorScheme;
|
||||
|
||||
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
|
||||
// Use system color schemes
|
||||
// Use system color schemes, but override background to match app design
|
||||
// Material You doesn't set background consistently, causing black screen issues
|
||||
final (light, dark) = snapshot.data!;
|
||||
lightColorScheme = light;
|
||||
darkColorScheme = dark;
|
||||
lightColorScheme = light.copyWith(
|
||||
surface: light.surface,
|
||||
background: const Color(0xFFFAFAFA), // App's preferred light background
|
||||
);
|
||||
darkColorScheme = dark.copyWith(
|
||||
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
|
||||
background: const Color(0xFF1a1a1a), // App's preferred dark background
|
||||
);
|
||||
} else {
|
||||
// Use custom color from theme provider
|
||||
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
||||
@@ -328,8 +350,8 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark
|
||||
? widget.darkColorScheme.background
|
||||
: widget.lightColorScheme.background,
|
||||
? widget.darkColorScheme.surface
|
||||
: widget.lightColorScheme.surface,
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -87,8 +87,31 @@ class Player {
|
||||
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && 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)
|
||||
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)
|
||||
double get currentElapsedTime {
|
||||
@@ -130,13 +153,8 @@ class Player {
|
||||
if (!_playerCreationTimes.containsKey(creationKey)) {
|
||||
// First time seeing this elapsed_time - record when we saw it
|
||||
_playerCreationTimes[creationKey] = now;
|
||||
// Clean up old entries to prevent memory leak
|
||||
if (_playerCreationTimes.length > 100) {
|
||||
final keysToRemove = _playerCreationTimes.keys.take(50).toList();
|
||||
for (final key in keysToRemove) {
|
||||
_playerCreationTimes.remove(key);
|
||||
}
|
||||
}
|
||||
// Clean up old entries to prevent memory leak (LRU eviction)
|
||||
_cleanupCreationTimes();
|
||||
}
|
||||
|
||||
final creationTime = _playerCreationTimes[creationKey]!;
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../services/music_assistant_api.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
|
||||
/// Provider for managing library data (artists, albums, tracks, playlists)
|
||||
@@ -138,8 +139,12 @@ class LibraryProvider with ChangeNotifier {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
_logger.log('🎨 loadLibrary using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
||||
|
||||
final results = await Future.wait([
|
||||
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
|
||||
_api!.getArtists(limit: LibraryConstants.maxLibraryItems, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
||||
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
||||
]);
|
||||
@@ -166,10 +171,14 @@ class LibraryProvider with ChangeNotifier {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
|
||||
_artists = await _api!.getArtists(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
albumArtistsOnly: showOnlyArtistsWithAlbums,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
|
||||
@@ -559,29 +559,28 @@ class PlayerProvider with ChangeNotifier {
|
||||
if (playerToSelect == null) {
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
|
||||
// Priority 1: Last selected player
|
||||
if (lastSelectedPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == lastSelectedPlayerId && p.available,
|
||||
);
|
||||
} catch (e) {}
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.playerId == lastSelectedPlayerId && p.available,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
// Priority 2: Built-in (local) player
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
} catch (e) {}
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.playerId == builtinPlayerId && p.available,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
|
||||
);
|
||||
} catch (e) {}
|
||||
playerToSelect = _availablePlayers.cast<Player?>().firstWhere(
|
||||
(p) => p!.state == 'playing' && p.available && !p.isExternalSource,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
|
||||
@@ -35,6 +36,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
List<Track> _tracks = [];
|
||||
bool _isLoading = true;
|
||||
bool _isFavorite = false;
|
||||
bool _isInLibrary = false;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
int? _expandedTrackIndex;
|
||||
@@ -51,6 +53,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.album.favorite ?? false;
|
||||
_isInLibrary = widget.album.inLibrary;
|
||||
_loadTracks();
|
||||
_loadAlbumDescription();
|
||||
|
||||
@@ -139,7 +142,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
orElse: () => widget.album.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
|
||||
actualProvider = mapping.providerDomain;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
@@ -170,7 +174,6 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
throw Exception('Could not determine library ID for this album');
|
||||
}
|
||||
|
||||
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
@@ -213,6 +216,104 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle library status
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
String? actualProvider;
|
||||
String? actualItemId;
|
||||
|
||||
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
|
||||
// For adding to library, we MUST use a non-library provider
|
||||
final nonLibraryMapping = widget.album.providerMappings!.where(
|
||||
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
||||
).firstOrNull;
|
||||
|
||||
if (nonLibraryMapping != null) {
|
||||
actualProvider = nonLibraryMapping.providerDomain;
|
||||
actualItemId = nonLibraryMapping.itemId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to item's own provider if no non-library mapping found
|
||||
if (actualProvider == null || actualItemId == null) {
|
||||
if (widget.album.provider != 'library') {
|
||||
actualProvider = widget.album.provider;
|
||||
actualItemId = widget.album.itemId;
|
||||
} else {
|
||||
// Item is library-only, can't add
|
||||
_logger.log('Cannot add to library: album is library-only');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
success = await maProvider.addToLibrary(
|
||||
mediaType: 'album',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
);
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
if (widget.album.provider == 'library') {
|
||||
libraryItemId = int.tryParse(widget.album.itemId);
|
||||
} else if (widget.album.providerMappings != null) {
|
||||
final libraryMapping = widget.album.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => widget.album.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) {
|
||||
_logger.log('Cannot remove from library: no library ID found');
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling album library: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to update library: $e'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleTrackFavorite(int trackIndex) async {
|
||||
if (trackIndex < 0 || trackIndex >= _tracks.length) return;
|
||||
|
||||
@@ -259,7 +360,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
orElse: () => track.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
|
||||
actualProvider = mapping.providerDomain;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
@@ -705,8 +807,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
backgroundColor: colorScheme.background,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 50% of screen width, clamped between 200-320
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
||||
// Responsive cover size: 70% of screen width, clamped between 200-320
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
@@ -730,35 +832,50 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
const SizedBox(height: 60),
|
||||
GestureDetector(
|
||||
onTap: () => _showFullscreenArt(imageUrl),
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
// Shadow container (outside Hero for correct clipping)
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -768,7 +885,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -909,17 +1026,32 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Library Button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _toggleLibrary,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_isInLibrary ? Icons.library_add_check : Icons.library_add,
|
||||
color: _isInLibrary
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 +1227,62 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
_showPlayOnSheet(
|
||||
context,
|
||||
players,
|
||||
onPlayerSelected: (player) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.playTracks(
|
||||
await maProvider.playTracks(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: startIndex,
|
||||
);
|
||||
},
|
||||
);
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
}
|
||||
|
||||
void _showPlayRadioMenu(BuildContext context, int trackIndex) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
final track = _tracks[trackIndex];
|
||||
|
||||
_showPlayOnSheet(
|
||||
context,
|
||||
players,
|
||||
onPlayerSelected: (player) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.playRadio(player.playerId, track);
|
||||
await maProvider.playRadio(player.playerId, track);
|
||||
},
|
||||
);
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
}
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
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();
|
||||
|
||||
showModalBottomSheet(
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
await maProvider.playTracks(player.playerId, _tracks);
|
||||
},
|
||||
).whenComplete(() {
|
||||
// Slide mini player back up when sheet is dismissed
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,11 +36,13 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
List<Album> _providerAlbums = [];
|
||||
bool _isLoading = true;
|
||||
bool _isFavorite = false;
|
||||
bool _isInLibrary = false;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
bool _isDescriptionExpanded = false;
|
||||
String? _artistDescription;
|
||||
String? _artistImageUrl;
|
||||
MusicAssistantProvider? _maProvider;
|
||||
|
||||
// View preferences
|
||||
String _sortOrder = 'alpha'; // 'alpha' or 'year'
|
||||
@@ -52,6 +54,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFavorite = widget.artist.favorite ?? false;
|
||||
_isInLibrary = _checkIfInLibrary(widget.artist);
|
||||
// Use initial image URL immediately for smooth hero animation
|
||||
_artistImageUrl = widget.initialImageUrl;
|
||||
_loadViewPreferences();
|
||||
@@ -66,11 +69,60 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
if (mounted) {
|
||||
_loadArtistImage();
|
||||
// Note: _extractColors is called by _loadArtistImage after image loads
|
||||
|
||||
// CRITICAL FIX: Delay adding provider listener until AFTER Hero animation
|
||||
// Adding it immediately causes rebuilds during animation (jank)
|
||||
_maProvider = context.read<MusicAssistantProvider>();
|
||||
_maProvider?.addListener(_onProviderChanged);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _onProviderChanged() {
|
||||
if (!mounted) return;
|
||||
// Re-check library status when provider data changes
|
||||
final newIsInLibrary = _checkIfInLibraryFromProvider();
|
||||
if (newIsInLibrary != _isInLibrary) {
|
||||
setState(() {
|
||||
_isInLibrary = newIsInLibrary;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if artist is in library using provider's artists list
|
||||
bool _checkIfInLibraryFromProvider() {
|
||||
if (_maProvider == null) return _checkIfInLibrary(widget.artist);
|
||||
|
||||
final artistName = widget.artist.name.toLowerCase();
|
||||
final artistUri = widget.artist.uri;
|
||||
|
||||
// Check if this artist exists in the provider's library
|
||||
return _maProvider!.artists.any((a) {
|
||||
// Match by URI if available
|
||||
if (artistUri != null && a.uri == artistUri) return true;
|
||||
// Match by name as fallback
|
||||
if (a.name.toLowerCase() == artistName) return true;
|
||||
// Check provider mappings for matching URIs
|
||||
if (widget.artist.providerMappings != null) {
|
||||
for (final mapping in widget.artist.providerMappings!) {
|
||||
if (a.providerMappings?.any((m) =>
|
||||
m.providerInstance == mapping.providerInstance &&
|
||||
m.itemId == mapping.itemId) == true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_maProvider?.removeListener(_onProviderChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadViewPreferences() async {
|
||||
final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
|
||||
final viewMode = await SettingsService.getArtistAlbumsViewMode();
|
||||
@@ -172,7 +224,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
orElse: () => widget.artist.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
|
||||
actualProvider = mapping.providerDomain;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
@@ -202,7 +255,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
throw Exception('Could not determine library ID for this artist');
|
||||
}
|
||||
|
||||
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
@@ -243,6 +295,110 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if artist is in library
|
||||
bool _checkIfInLibrary(Artist artist) {
|
||||
if (artist.provider == 'library') return true;
|
||||
return artist.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
/// Toggle library status
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
String? actualProvider;
|
||||
String? actualItemId;
|
||||
|
||||
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
|
||||
// For adding to library, we MUST use a non-library provider
|
||||
final nonLibraryMapping = widget.artist.providerMappings!.where(
|
||||
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
||||
).firstOrNull;
|
||||
|
||||
if (nonLibraryMapping != null) {
|
||||
actualProvider = nonLibraryMapping.providerDomain;
|
||||
actualItemId = nonLibraryMapping.itemId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to item's own provider if no non-library mapping found
|
||||
if (actualProvider == null || actualItemId == null) {
|
||||
if (widget.artist.provider != 'library') {
|
||||
actualProvider = widget.artist.provider;
|
||||
actualItemId = widget.artist.itemId;
|
||||
} else {
|
||||
// Item is library-only, can't add
|
||||
_logger.log('Cannot add to library: artist is library-only');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
success = await maProvider.addToLibrary(
|
||||
mediaType: 'artist',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
);
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
if (widget.artist.provider == 'library') {
|
||||
libraryItemId = int.tryParse(widget.artist.itemId);
|
||||
} else if (widget.artist.providerMappings != null) {
|
||||
final libraryMapping = widget.artist.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => widget.artist.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) {
|
||||
_logger.log('Cannot remove from library: no library ID found');
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling artist library: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to update library: $e'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showRadioMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
@@ -553,8 +709,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
backgroundColor: colorScheme.background,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 40% of screen width, clamped between 160-280 (smaller for circular artist image)
|
||||
final coverSize = (constraints.maxWidth * 0.4).clamp(160.0, 280.0);
|
||||
// Responsive cover size: 70% of screen width, clamped between 160-280 (smaller for circular artist image)
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||
final expandedHeight = coverSize + 100;
|
||||
|
||||
return CustomScrollView(
|
||||
@@ -587,8 +743,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.person_rounded,
|
||||
size: coverSize * 0.5,
|
||||
@@ -712,6 +872,29 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Library Button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _toggleLibrary,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_isInLibrary ? Icons.library_add_check : Icons.library_add,
|
||||
color: _isInLibrary
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
@@ -139,7 +140,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
orElse: () => widget.audiobook.providerMappings!.first,
|
||||
),
|
||||
);
|
||||
actualProvider = mapping.providerInstance;
|
||||
// Use providerDomain (e.g., "spotify") not providerInstance (e.g., "spotify--xyz")
|
||||
actualProvider = mapping.providerDomain;
|
||||
actualItemId = mapping.itemId;
|
||||
}
|
||||
|
||||
@@ -168,7 +170,6 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
throw Exception('Could not determine library ID for this audiobook');
|
||||
}
|
||||
|
||||
_logger.log('Removing audiobook from favorites: libraryItemId=$libraryItemId');
|
||||
success = await maProvider.removeFromFavorites(
|
||||
mediaType: 'audiobook',
|
||||
libraryItemId: libraryItemId,
|
||||
@@ -209,65 +210,23 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
// Slide mini player down out of the way
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showModalBottomSheet(
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
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: () 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.setCurrentAudiobook(_audiobook);
|
||||
await maProvider.api?.playAudiobook(
|
||||
player.playerId,
|
||||
widget.audiobook,
|
||||
);
|
||||
},
|
||||
).whenComplete(() {
|
||||
// Slide mini player back up when sheet is dismissed
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
}
|
||||
@@ -470,8 +429,8 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 50% of screen width, clamped between 200-320
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
||||
// Responsive cover size: 70% of screen width, clamped between 200-320
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
@@ -553,6 +512,9 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
|
||||
@@ -277,8 +277,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 50% of screen width, clamped between 200-320
|
||||
final coverSize = (constraints.maxWidth * 0.5).clamp(200.0, 320.0);
|
||||
// Responsive cover size: 70% of screen width, clamped between 200-320
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
@@ -451,7 +451,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
_buildAudiobookSliver(maProvider),
|
||||
|
||||
// Bottom padding for mini player
|
||||
const SliverToBoxAdapter(
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -68,6 +68,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// If queue panel is open, close it first (before collapsing player)
|
||||
if (GlobalPlayerOverlay.isQueuePanelOpen) {
|
||||
GlobalPlayerOverlay.closeQueuePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// If global player is expanded, collapse it first
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
|
||||
@@ -64,7 +64,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
||||
|
||||
return RefreshIndicator(
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: colorScheme.background,
|
||||
onRefresh: () async {
|
||||
await context.read<MusicAssistantProvider>().loadLibrary();
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
||||
|
||||
return RefreshIndicator(
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: colorScheme.background,
|
||||
onRefresh: () async {
|
||||
await context.read<MusicAssistantProvider>().loadLibrary();
|
||||
},
|
||||
|
||||
@@ -30,17 +30,11 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
});
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api != null) {
|
||||
final playlists = await maProvider.api!.getPlaylists(limit: 100);
|
||||
setState(() {
|
||||
_playlists = playlists;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
final playlists = await maProvider.getPlaylists(limit: 100);
|
||||
setState(() {
|
||||
_playlists = playlists;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -89,7 +83,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
|
||||
return RefreshIndicator(
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: colorScheme.background,
|
||||
onRefresh: _loadPlaylists,
|
||||
child: ListView.builder(
|
||||
itemCount: _playlists.length,
|
||||
|
||||
@@ -13,6 +13,8 @@ import '../widgets/artist_row.dart';
|
||||
import '../widgets/track_row.dart';
|
||||
import '../widgets/audiobook_row.dart';
|
||||
import '../widgets/series_row.dart';
|
||||
import '../widgets/playlist_row.dart';
|
||||
import '../widgets/radio_station_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -35,6 +37,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
// Audiobook rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
@@ -73,6 +77,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
@@ -85,6 +91,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
@@ -194,7 +202,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
backgroundColor: colorScheme.background,
|
||||
child: _buildConnectedView(context, maProvider),
|
||||
),
|
||||
// Connecting banner overlay (doesn't affect layout)
|
||||
@@ -243,8 +251,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
// Use LayoutBuilder to get available screen height
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Simple calculation: available height divided by 3 rows
|
||||
// Each row includes its title, artwork, and info
|
||||
// Each row is always 1/3 of screen height
|
||||
// 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll
|
||||
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||
final rowHeight = availableHeight / 3;
|
||||
|
||||
@@ -330,7 +338,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
return AudiobookRow(
|
||||
key: const ValueKey('continue-listening'),
|
||||
title: S.of(context)!.continueListening,
|
||||
loadAudiobooks: () => provider.getInProgressAudiobooks(),
|
||||
loadAudiobooks: () => provider.getInProgressAudiobooksWithCache(),
|
||||
getCachedAudiobooks: () => provider.getCachedInProgressAudiobooks(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'discover-audiobooks':
|
||||
@@ -338,7 +347,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
return AudiobookRow(
|
||||
key: const ValueKey('discover-audiobooks'),
|
||||
title: S.of(context)!.discoverAudiobooks,
|
||||
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
|
||||
loadAudiobooks: () => provider.getDiscoverAudiobooksWithCache(),
|
||||
getCachedAudiobooks: () => provider.getCachedDiscoverAudiobooks(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'discover-series':
|
||||
@@ -346,7 +356,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
return SeriesRow(
|
||||
key: const ValueKey('discover-series'),
|
||||
title: S.of(context)!.discoverSeries,
|
||||
loadSeries: () => provider.getDiscoverSeries(),
|
||||
loadSeries: () => provider.getDiscoverSeriesWithCache(),
|
||||
getCachedSeries: () => provider.getCachedDiscoverSeries(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-albums':
|
||||
@@ -373,6 +384,24 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
loadTracks: () => provider.getFavoriteTracks(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-playlists':
|
||||
if (!_showFavoritePlaylists) return null;
|
||||
return PlaylistRow(
|
||||
key: const ValueKey('favorite-playlists'),
|
||||
title: S.of(context)!.favoritePlaylists,
|
||||
loadPlaylists: () => provider.getFavoritePlaylists(),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-radio-stations':
|
||||
if (!_showFavoriteRadioStations) return null;
|
||||
return RadioStationRow(
|
||||
key: const ValueKey('favorite-radio-stations'),
|
||||
title: S.of(context)!.favoriteRadioStations,
|
||||
loadRadioStations: () => provider.getFavoriteRadioStations(),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,873 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class PodcastDetailScreen extends StatefulWidget {
|
||||
final MediaItem podcast;
|
||||
final String? heroTagSuffix;
|
||||
final String? initialImageUrl;
|
||||
|
||||
const PodcastDetailScreen({
|
||||
super.key,
|
||||
required this.podcast,
|
||||
this.heroTagSuffix,
|
||||
this.initialImageUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PodcastDetailScreen> createState() => _PodcastDetailScreenState();
|
||||
}
|
||||
|
||||
class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
|
||||
final _logger = DebugLogger();
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
List<MediaItem> _episodes = [];
|
||||
bool _isLoadingEpisodes = false;
|
||||
bool _isDescriptionExpanded = false;
|
||||
bool _isInLibrary = false;
|
||||
String? _expandedEpisodeId; // Track which episode is expanded for actions
|
||||
|
||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isInLibrary = _checkIfInLibrary(widget.podcast);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
if (mounted) {
|
||||
_extractColors();
|
||||
}
|
||||
});
|
||||
_loadEpisodes();
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if podcast is in library
|
||||
bool _checkIfInLibrary(MediaItem podcast) {
|
||||
if (podcast.provider == 'library') return true;
|
||||
return podcast.providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||
}
|
||||
|
||||
/// Toggle library status
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
bool success;
|
||||
|
||||
if (newState) {
|
||||
// Add to library - MUST use non-library provider
|
||||
String? actualProvider;
|
||||
String? actualItemId;
|
||||
|
||||
if (widget.podcast.providerMappings != null && widget.podcast.providerMappings!.isNotEmpty) {
|
||||
// For adding to library, we MUST use a non-library provider
|
||||
final nonLibraryMapping = widget.podcast.providerMappings!.where(
|
||||
(m) => m.providerInstance != 'library' && m.providerDomain != 'library',
|
||||
).firstOrNull;
|
||||
|
||||
if (nonLibraryMapping != null) {
|
||||
actualProvider = nonLibraryMapping.providerDomain;
|
||||
actualItemId = nonLibraryMapping.itemId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to item's own provider if no non-library mapping found
|
||||
if (actualProvider == null || actualItemId == null) {
|
||||
if (widget.podcast.provider != 'library') {
|
||||
actualProvider = widget.podcast.provider;
|
||||
actualItemId = widget.podcast.itemId;
|
||||
} else {
|
||||
// Item is library-only, can't add
|
||||
_logger.log('Cannot add to library: podcast is library-only');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
success = await maProvider.addToLibrary(
|
||||
mediaType: 'podcast',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
);
|
||||
} else {
|
||||
// Remove from library
|
||||
int? libraryItemId;
|
||||
if (widget.podcast.provider == 'library') {
|
||||
libraryItemId = int.tryParse(widget.podcast.itemId);
|
||||
} else if (widget.podcast.providerMappings != null) {
|
||||
final libraryMapping = widget.podcast.providerMappings!.firstWhere(
|
||||
(m) => m.providerInstance == 'library',
|
||||
orElse: () => widget.podcast.providerMappings!.first,
|
||||
);
|
||||
if (libraryMapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId == null) {
|
||||
_logger.log('Cannot remove from library: no library ID found');
|
||||
return;
|
||||
}
|
||||
|
||||
success = await maProvider.removeFromLibrary(
|
||||
mediaType: 'podcast',
|
||||
libraryItemId: libraryItemId,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isInLibrary ? S.of(context)!.addedToLibrary : S.of(context)!.removedFromLibrary,
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error toggling podcast library: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to update library: $e'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extractColors() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
// Use initialImageUrl first for consistency with hero animation
|
||||
final imageUrl = widget.initialImageUrl ?? maProvider.getPodcastImageUrl(widget.podcast);
|
||||
|
||||
if (imageUrl != null) {
|
||||
try {
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
);
|
||||
if (mounted && colorSchemes != null) {
|
||||
setState(() {
|
||||
_lightColorScheme = colorSchemes.$1;
|
||||
_darkColorScheme = colorSchemes.$2;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Error extracting colors: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadEpisodes() async {
|
||||
if (_isLoadingEpisodes) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingEpisodes = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api != null) {
|
||||
final episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
_logger.log('🎙️ Loaded ${episodes.length} episodes for ${widget.podcast.name}');
|
||||
// Debug: Log first episode metadata to see available fields
|
||||
if (episodes.isNotEmpty) {
|
||||
final first = episodes.first;
|
||||
_logger.log('🎙️ First episode metadata keys: ${first.metadata?.keys.toList()}');
|
||||
_logger.log('🎙️ First episode metadata: ${first.metadata}');
|
||||
}
|
||||
setState(() {
|
||||
_episodes = episodes;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Error loading episodes: $e');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _playEpisode(MediaItem episode) async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
|
||||
if (selectedPlayer != null) {
|
||||
try {
|
||||
// Set podcast context before playing so player UI shows correct podcast name
|
||||
maProvider.setCurrentPodcastName(widget.podcast.name);
|
||||
await maProvider.api?.playPodcastEpisode(selectedPlayer.playerId, episode);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing: ${episode.name}'),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Error playing episode: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to play episode: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_showPlayOnMenu(context, episode);
|
||||
}
|
||||
}
|
||||
|
||||
void _showPlayOnMenu(BuildContext context, MediaItem episode) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.playOn,
|
||||
players: maProvider.availablePlayers,
|
||||
selectedPlayer: maProvider.selectedPlayer,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
// Set podcast context before playing so player UI shows correct podcast name
|
||||
maProvider.setCurrentPodcastName(widget.podcast.name);
|
||||
await maProvider.api?.playPodcastEpisode(player.playerId, episode);
|
||||
},
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
}
|
||||
|
||||
String _formatDuration(Duration? duration) {
|
||||
if (duration == null || duration == Duration.zero) return '';
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
return '${minutes} min';
|
||||
}
|
||||
|
||||
/// Format release date from episode metadata
|
||||
/// Tries various common field names for publication date
|
||||
String? _formatReleaseDate(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null) return null;
|
||||
|
||||
// Try various common field names for publication date
|
||||
dynamic dateValue = metadata['published'] ??
|
||||
metadata['pub_date'] ??
|
||||
metadata['release_date'] ??
|
||||
metadata['aired'] ??
|
||||
metadata['timestamp'];
|
||||
|
||||
if (dateValue == null) return null;
|
||||
|
||||
try {
|
||||
DateTime? date;
|
||||
if (dateValue is String) {
|
||||
date = DateTime.tryParse(dateValue);
|
||||
} else if (dateValue is int) {
|
||||
// Unix timestamp
|
||||
date = DateTime.fromMillisecondsSinceEpoch(dateValue * 1000);
|
||||
}
|
||||
|
||||
if (date != null) {
|
||||
// Format as "Jan 15, 2024"
|
||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return '${months[date.month - 1]} ${date.day}, ${date.year}';
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Error parsing release date: $e');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Add episode to queue on current player
|
||||
Future<void> _addToQueue(MediaItem episode) async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
|
||||
if (selectedPlayer == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert MediaItem to Track for queue addition
|
||||
final track = Track(
|
||||
itemId: episode.itemId,
|
||||
provider: episode.provider,
|
||||
name: episode.name,
|
||||
uri: episode.uri,
|
||||
duration: episode.duration,
|
||||
metadata: episode.metadata,
|
||||
);
|
||||
await maProvider.addTrackToQueue(selectedPlayer.playerId, track);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToQueue),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Error adding episode to queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.failedToAddToQueue(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// CRITICAL FIX: Use select() instead of watch() to prevent rebuilds during Hero animation
|
||||
// watch() rebuilds on ANY provider change (library updates, etc.) causing animation jank
|
||||
// select() only rebuilds when the specific podcast image URL changes
|
||||
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
|
||||
(provider) => provider.getPodcastImageUrl(widget.podcast),
|
||||
);
|
||||
// Use initialImageUrl for seamless hero animation (same URL as source)
|
||||
final imageUrl = widget.initialImageUrl ?? providerImageUrl;
|
||||
|
||||
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||
(provider) => provider.adaptiveTheme,
|
||||
);
|
||||
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||
(provider) => provider.adaptiveLightScheme,
|
||||
);
|
||||
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||
(provider) => provider.adaptiveDarkScheme,
|
||||
);
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
ColorScheme? adaptiveScheme;
|
||||
if (adaptiveTheme) {
|
||||
adaptiveScheme = isDark
|
||||
? (_darkColorScheme ?? adaptiveDarkScheme)
|
||||
: (_lightColorScheme ?? adaptiveLightScheme);
|
||||
}
|
||||
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final description = widget.podcast.metadata?['description'] as String?;
|
||||
final author = widget.podcast.metadata?['author'] as String?;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) {
|
||||
clearAdaptiveColorsOnBack(context);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(200.0, 320.0);
|
||||
final expandedHeight = coverSize + 70;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () {
|
||||
clearAdaptiveColorsOnBack(context);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
Hero(
|
||||
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + _heroTagSuffix,
|
||||
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Match source memCacheWidth for smooth Hero animation
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Podcast title
|
||||
Text(
|
||||
widget.podcast.name,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (author != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
author,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Action buttons row
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Main Play Latest Episode Button
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoadingEpisodes || _episodes.isEmpty
|
||||
? null
|
||||
: () => _playEpisode(_episodes.first),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(S.of(context)!.play),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
|
||||
disabledForegroundColor: colorScheme.onPrimary.withOpacity(0.38),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// "Play on..." Button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _isLoadingEpisodes || _episodes.isEmpty
|
||||
? null
|
||||
: () => _showPlayOnMenu(context, _episodes.first),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.speaker_group_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Library button
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _toggleLibrary,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_isInLibrary ? Icons.library_add_check : Icons.library_add,
|
||||
color: _isInLibrary
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description
|
||||
if (description != null && description.isNotEmpty) ...[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isDescriptionExpanded = !_isDescriptionExpanded;
|
||||
});
|
||||
},
|
||||
child: AnimatedCrossFade(
|
||||
firstChild: Text(
|
||||
description,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
secondChild: Text(
|
||||
description,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
crossFadeState: _isDescriptionExpanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Episodes header
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.episodes,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_episodes.isNotEmpty)
|
||||
Text(
|
||||
'(${_episodes.length})',
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Episodes list
|
||||
if (_isLoadingEpisodes)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_episodes.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
MdiIcons.microphoneOff,
|
||||
size: 48,
|
||||
color: colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No episodes found',
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final episode = _episodes[index];
|
||||
final episodeDescription = episode.metadata?['description'] as String?;
|
||||
final duration = episode.duration;
|
||||
final episodeId = episode.uri ?? episode.itemId;
|
||||
final isExpanded = _expandedEpisodeId == episodeId;
|
||||
final episodeImageUrl = context.read<MusicAssistantProvider>().getImageUrl(episode, size: 256);
|
||||
|
||||
// Try to get release date from metadata
|
||||
final releaseDate = _formatReleaseDate(episode.metadata);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => _playEpisode(episode),
|
||||
onLongPress: () {
|
||||
setState(() {
|
||||
_expandedEpisodeId = isExpanded ? null : episodeId;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Episode cover (bigger than before)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: episodeImageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: episodeImageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.podcast,
|
||||
size: 32,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Episode info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
episode.name,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (episodeDescription != null && episodeDescription.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
episodeDescription,
|
||||
maxLines: isExpanded ? 100 : 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
// Duration and release date row
|
||||
Row(
|
||||
children: [
|
||||
if (duration != null && duration > Duration.zero) ...[
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (duration != null && duration > Duration.zero && releaseDate != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'•',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (releaseDate != null)
|
||||
Text(
|
||||
releaseDate,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Expanded action buttons (matching album track style)
|
||||
AnimatedCrossFade(
|
||||
firstChild: const SizedBox.shrink(),
|
||||
secondChild: Padding(
|
||||
padding: const EdgeInsets.only(left: 100, right: 16, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Play button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _playEpisode(episode),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Play on... button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showPlayOnMenu(context, episode),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.speaker_group_outlined, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Add to queue button
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: 44,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _addToQueue(episode),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.playlist_add, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
crossFadeState: isExpanded
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
childCount: _episodes.length,
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom padding for mini player
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 100),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
// Audiobook home rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
@@ -41,8 +43,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
bool _volumePrecisionMode = true;
|
||||
// Hint settings
|
||||
bool _showHints = true;
|
||||
// Library settings
|
||||
bool _showOnlyArtistsWithAlbums = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -68,6 +73,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||
|
||||
// Load audiobook home row settings
|
||||
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
@@ -90,10 +97,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load player settings
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||
|
||||
// Load hint settings
|
||||
final showHints = await SettingsService.getShowHints();
|
||||
|
||||
// Load library settings
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -102,6 +113,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
@@ -110,7 +123,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_libraryEnabled = libraryEnabled;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -144,6 +159,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
|
||||
case 'favorite-tracks':
|
||||
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
|
||||
case 'favorite-playlists':
|
||||
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
|
||||
case 'favorite-radio-stations':
|
||||
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
|
||||
default:
|
||||
return {'title': rowId, 'subtitle': ''};
|
||||
}
|
||||
@@ -170,6 +189,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return _showFavoriteArtists;
|
||||
case 'favorite-tracks':
|
||||
return _showFavoriteTracks;
|
||||
case 'favorite-playlists':
|
||||
return _showFavoritePlaylists;
|
||||
case 'favorite-radio-stations':
|
||||
return _showFavoriteRadioStations;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -215,6 +238,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteTracks = value;
|
||||
SettingsService.setShowFavoriteTracks(value);
|
||||
break;
|
||||
case 'favorite-playlists':
|
||||
_showFavoritePlaylists = value;
|
||||
SettingsService.setShowFavoritePlaylists(value);
|
||||
break;
|
||||
case 'favorite-radio-stations':
|
||||
_showFavoriteRadioStations = value;
|
||||
SettingsService.setShowFavoriteRadioStations(value);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -281,13 +312,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Logo - same size as login screen (50% of screen width)
|
||||
// Use color filter to make logo dark in light theme
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
|
||||
child: Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
child: Theme.of(context).brightness == Brightness.light
|
||||
? ColorFiltered(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Color(0xFF1a1a1a), // Dark color for light theme
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
)
|
||||
: Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
// Connection status box - centered with border radius like theme boxes
|
||||
@@ -702,6 +746,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
'Volume precision mode',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Hold still while adjusting volume for fine control',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _volumePrecisionMode,
|
||||
onChanged: (value) {
|
||||
setState(() => _volumePrecisionMode = value);
|
||||
SettingsService.setVolumePrecisionMode(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
@@ -745,6 +814,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Library section
|
||||
Text(
|
||||
S.of(context)!.library,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
'Show only artists with albums',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Hide artists that have no albums in your library',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
value: _showOnlyArtistsWithAlbums,
|
||||
onChanged: (value) async {
|
||||
setState(() => _showOnlyArtistsWithAlbums = value);
|
||||
await SettingsService.setShowOnlyArtistsWithAlbums(value);
|
||||
// Force sync library to apply the new filter at API level
|
||||
if (mounted) {
|
||||
context.read<MusicAssistantProvider>().forceLibrarySync();
|
||||
}
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Home Screen section
|
||||
Text(
|
||||
S.of(context)!.homeScreen,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
@@ -11,6 +12,12 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
final AuthManager authManager;
|
||||
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
|
||||
// This allows us to update the notification when metadata arrives late
|
||||
MediaItem? _currentMediaItem;
|
||||
@@ -42,7 +49,7 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
await session.configure(const AudioSessionConfiguration.music());
|
||||
|
||||
// Handle audio interruptions
|
||||
session.interruptionEventStream.listen((event) {
|
||||
_interruptionSubscription = session.interruptionEventStream.listen((event) {
|
||||
if (event.begin) {
|
||||
switch (event.type) {
|
||||
case AudioInterruptionType.duck:
|
||||
@@ -68,15 +75,15 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
});
|
||||
|
||||
// Handle becoming noisy (headphones unplugged)
|
||||
session.becomingNoisyEventStream.listen((_) {
|
||||
_becomingNoisySubscription = session.becomingNoisyEventStream.listen((_) {
|
||||
pause();
|
||||
});
|
||||
|
||||
// Broadcast playback state changes
|
||||
_player.playbackEventStream.listen(_broadcastState);
|
||||
_playbackEventSubscription = _player.playbackEventStream.listen(_broadcastState);
|
||||
|
||||
// Broadcast current media item changes
|
||||
_player.currentIndexStream.listen((_) {
|
||||
_currentIndexSubscription = _player.currentIndexStream.listen((_) {
|
||||
if (_currentMediaItem != null) {
|
||||
mediaItem.add(_currentMediaItem);
|
||||
}
|
||||
@@ -293,4 +300,13 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
Stream<Duration?> get durationStream => _player.durationStream;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ class AuthManager {
|
||||
host.startsWith('172.31.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local');
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.ts.net');
|
||||
}
|
||||
|
||||
/// Try to detect auth strategy for a given URL
|
||||
|
||||
@@ -11,13 +11,24 @@ class CacheService {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
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
|
||||
List<Album>? _cachedRecentAlbums;
|
||||
List<Artist>? _cachedDiscoverArtists;
|
||||
List<Album>? _cachedDiscoverAlbums;
|
||||
List<Audiobook>? _cachedInProgressAudiobooks;
|
||||
List<Audiobook>? _cachedDiscoverAudiobooks;
|
||||
List<AudiobookSeries>? _cachedDiscoverSeries;
|
||||
DateTime? _recentAlbumsLastFetched;
|
||||
DateTime? _discoverArtistsLastFetched;
|
||||
DateTime? _discoverAlbumsLastFetched;
|
||||
DateTime? _inProgressAudiobooksLastFetched;
|
||||
DateTime? _discoverAudiobooksLastFetched;
|
||||
DateTime? _discoverSeriesLastFetched;
|
||||
|
||||
// Detail screen caching
|
||||
final Map<String, List<Track>> _albumTracksCache = {};
|
||||
@@ -38,6 +49,7 @@ class CacheService {
|
||||
|
||||
// Player track cache (for smooth swipe transitions)
|
||||
final Map<String, Track?> _playerTrackCache = {};
|
||||
final Map<String, DateTime> _playerTrackCacheTime = {};
|
||||
|
||||
// ============================================================================
|
||||
// HOME SCREEN ROW CACHING
|
||||
@@ -106,14 +118,101 @@ class CacheService {
|
||||
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if in-progress audiobooks cache is valid
|
||||
bool isInProgressAudiobooksCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final now = DateTime.now();
|
||||
return _cachedInProgressAudiobooks != null &&
|
||||
_inProgressAudiobooksLastFetched != null &&
|
||||
now.difference(_inProgressAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
|
||||
}
|
||||
|
||||
/// Get cached in-progress audiobooks
|
||||
List<Audiobook>? getCachedInProgressAudiobooks() => _cachedInProgressAudiobooks;
|
||||
|
||||
/// Set cached in-progress audiobooks
|
||||
void setCachedInProgressAudiobooks(List<Audiobook> audiobooks) {
|
||||
_cachedInProgressAudiobooks = audiobooks;
|
||||
_inProgressAudiobooksLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${audiobooks.length} in-progress audiobooks');
|
||||
}
|
||||
|
||||
/// Check if discover audiobooks cache is valid
|
||||
bool isDiscoverAudiobooksCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final now = DateTime.now();
|
||||
return _cachedDiscoverAudiobooks != null &&
|
||||
_discoverAudiobooksLastFetched != null &&
|
||||
now.difference(_discoverAudiobooksLastFetched!) < Timings.homeRowCacheDuration;
|
||||
}
|
||||
|
||||
/// Get cached discover audiobooks
|
||||
List<Audiobook>? getCachedDiscoverAudiobooks() => _cachedDiscoverAudiobooks;
|
||||
|
||||
/// Set cached discover audiobooks
|
||||
void setCachedDiscoverAudiobooks(List<Audiobook> audiobooks) {
|
||||
_cachedDiscoverAudiobooks = audiobooks;
|
||||
_discoverAudiobooksLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${audiobooks.length} discover audiobooks');
|
||||
}
|
||||
|
||||
/// Check if discover series cache is valid
|
||||
bool isDiscoverSeriesCacheValid({bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
final now = DateTime.now();
|
||||
return _cachedDiscoverSeries != null &&
|
||||
_discoverSeriesLastFetched != null &&
|
||||
now.difference(_discoverSeriesLastFetched!) < Timings.homeRowCacheDuration;
|
||||
}
|
||||
|
||||
/// Get cached discover series
|
||||
List<AudiobookSeries>? getCachedDiscoverSeries() => _cachedDiscoverSeries;
|
||||
|
||||
/// Set cached discover series
|
||||
void setCachedDiscoverSeries(List<AudiobookSeries> series) {
|
||||
_cachedDiscoverSeries = series;
|
||||
_discoverSeriesLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${series.length} discover series');
|
||||
}
|
||||
|
||||
/// Invalidate audiobook caches
|
||||
void invalidateAudiobookCaches() {
|
||||
_cachedInProgressAudiobooks = null;
|
||||
_inProgressAudiobooksLastFetched = null;
|
||||
_cachedDiscoverAudiobooks = null;
|
||||
_discoverAudiobooksLastFetched = null;
|
||||
_cachedDiscoverSeries = null;
|
||||
_discoverSeriesLastFetched = null;
|
||||
_logger.log('🗑️ Audiobook caches invalidated');
|
||||
}
|
||||
|
||||
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||
void invalidateHomeCache() {
|
||||
_recentAlbumsLastFetched = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_inProgressAudiobooksLastFetched = null;
|
||||
_discoverAudiobooksLastFetched = null;
|
||||
_discoverSeriesLastFetched = null;
|
||||
_logger.log('🗑️ Home screen cache invalidated');
|
||||
}
|
||||
|
||||
/// Invalidate home album caches (call when albums are added/removed from library)
|
||||
void invalidateHomeAlbumCaches() {
|
||||
_cachedRecentAlbums = null;
|
||||
_recentAlbumsLastFetched = null;
|
||||
_cachedDiscoverAlbums = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_logger.log('🗑️ Home album caches invalidated');
|
||||
}
|
||||
|
||||
/// Invalidate home artist caches (call when artists are added/removed from library)
|
||||
void invalidateHomeArtistCaches() {
|
||||
_cachedDiscoverArtists = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_logger.log('🗑️ Home artist caches invalidated');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DETAIL SCREEN CACHING
|
||||
// ============================================================================
|
||||
@@ -135,15 +234,23 @@ class CacheService {
|
||||
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
|
||||
_albumTracksCache[cacheKey] = tracks;
|
||||
_albumTracksCacheTime[cacheKey] = DateTime.now();
|
||||
_evictOldestEntries(_albumTracksCache, _albumTracksCacheTime, _maxDetailCacheSize);
|
||||
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
|
||||
}
|
||||
|
||||
/// Invalidate album tracks cache
|
||||
/// Invalidate album tracks cache for a specific album
|
||||
void invalidateAlbumTracksCache(String albumId) {
|
||||
_albumTracksCache.remove(albumId);
|
||||
_albumTracksCacheTime.remove(albumId);
|
||||
}
|
||||
|
||||
/// Invalidate all album tracks caches (call when tracks are added/removed from library)
|
||||
void invalidateAllAlbumTracksCaches() {
|
||||
_albumTracksCache.clear();
|
||||
_albumTracksCacheTime.clear();
|
||||
_logger.log('🗑️ All album tracks caches invalidated');
|
||||
}
|
||||
|
||||
/// Check if playlist tracks cache is valid
|
||||
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -161,15 +268,23 @@ class CacheService {
|
||||
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
|
||||
_playlistTracksCache[cacheKey] = tracks;
|
||||
_playlistTracksCacheTime[cacheKey] = DateTime.now();
|
||||
_evictOldestEntries(_playlistTracksCache, _playlistTracksCacheTime, _maxDetailCacheSize);
|
||||
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
|
||||
}
|
||||
|
||||
/// Invalidate playlist tracks cache
|
||||
/// Invalidate playlist tracks cache for a specific playlist
|
||||
void invalidatePlaylistTracksCache(String playlistId) {
|
||||
_playlistTracksCache.remove(playlistId);
|
||||
_playlistTracksCacheTime.remove(playlistId);
|
||||
}
|
||||
|
||||
/// Invalidate all playlist tracks caches (call when tracks are added/removed from library)
|
||||
void invalidateAllPlaylistTracksCaches() {
|
||||
_playlistTracksCache.clear();
|
||||
_playlistTracksCacheTime.clear();
|
||||
_logger.log('🗑️ All playlist tracks caches invalidated');
|
||||
}
|
||||
|
||||
/// Check if artist albums cache is valid
|
||||
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||
if (forceRefresh) return false;
|
||||
@@ -187,9 +302,17 @@ class CacheService {
|
||||
void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
|
||||
_artistAlbumsCache[cacheKey] = albums;
|
||||
_artistAlbumsCacheTime[cacheKey] = DateTime.now();
|
||||
_evictOldestEntries(_artistAlbumsCache, _artistAlbumsCacheTime, _maxDetailCacheSize);
|
||||
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
|
||||
}
|
||||
|
||||
/// Invalidate all artist albums caches (call when albums are added/removed from library)
|
||||
void invalidateArtistAlbumsCache() {
|
||||
_artistAlbumsCache.clear();
|
||||
_artistAlbumsCacheTime.clear();
|
||||
_logger.log('🗑️ Artist albums cache invalidated');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH CACHING
|
||||
// ============================================================================
|
||||
@@ -211,9 +334,17 @@ class CacheService {
|
||||
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
|
||||
_searchCache[cacheKey] = results;
|
||||
_searchCacheTime[cacheKey] = DateTime.now();
|
||||
_evictOldestEntries(_searchCache, _searchCacheTime, _maxSearchCacheSize);
|
||||
_logger.log('✅ Cached search results for "$cacheKey"');
|
||||
}
|
||||
|
||||
/// Invalidate all search cache (call when library items change)
|
||||
void invalidateSearchCache() {
|
||||
_searchCache.clear();
|
||||
_searchCacheTime.clear();
|
||||
_logger.log('🗑️ Search cache invalidated');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER CACHING
|
||||
// ============================================================================
|
||||
@@ -254,14 +385,20 @@ class CacheService {
|
||||
/// Get cached track for a player (used for smooth swipe transitions)
|
||||
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
|
||||
void setCachedTrackForPlayer(String playerId, Track? 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)
|
||||
void clearCachedTrackForPlayer(String playerId) {
|
||||
_playerTrackCache.remove(playerId);
|
||||
_playerTrackCacheTime.remove(playerId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -279,6 +416,7 @@ class CacheService {
|
||||
_searchCache.clear();
|
||||
_searchCacheTime.clear();
|
||||
_playerTrackCache.clear();
|
||||
_playerTrackCacheTime.clear();
|
||||
_logger.log('🗑️ All detail caches cleared');
|
||||
}
|
||||
|
||||
@@ -364,4 +502,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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,16 @@ class DatabaseService {
|
||||
/// Clear all cached data (useful for logout)
|
||||
Future<void> clearAllCache() => db.clearAllCache();
|
||||
|
||||
/// Clear cached items of a specific type (e.g., 'album', 'artist')
|
||||
/// Used before re-syncing to remove stale items
|
||||
Future<void> clearCacheForType(String itemType) => db.clearCache(itemType);
|
||||
|
||||
/// Mark a specific cached item as deleted
|
||||
/// Used when removing items from library for immediate database update
|
||||
Future<void> markCachedItemDeleted(String itemType, String itemId) {
|
||||
return db.markItemsDeleted(itemType, [itemId]);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Player Cache Convenience Methods
|
||||
// ============================================
|
||||
@@ -252,4 +262,53 @@ class DatabaseService {
|
||||
|
||||
/// Clear all search history
|
||||
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) {
|
||||
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
|
||||
}
|
||||
|
||||
// Close StreamControllers to prevent memory leaks
|
||||
await _volumeUpController.close();
|
||||
await _volumeDownController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,16 +293,10 @@ class MusicAssistantAPI {
|
||||
// Handle event
|
||||
final eventType = data['event'] as String?;
|
||||
if (eventType != null) {
|
||||
_logger.log('Event received: $eventType');
|
||||
|
||||
// Get the object_id (player_id for player events)
|
||||
final objectId = data['object_id'] as String?;
|
||||
final eventData = data['data'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
// Debug: Log full data for player events
|
||||
if (eventType == 'player_added' || eventType == 'player_updated' || eventType == 'builtin_player') {
|
||||
_logger.log('Event data: ${jsonEncode(eventData)} (player_id: $objectId)');
|
||||
}
|
||||
|
||||
// Include object_id in event data so listeners can filter by player
|
||||
final enrichedData = {
|
||||
@@ -498,6 +492,227 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<MediaItem>> getRadioStations({
|
||||
int? limit,
|
||||
int? offset,
|
||||
bool? favoriteOnly,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/radios/library_items',
|
||||
args: {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
},
|
||||
);
|
||||
|
||||
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
|
||||
return items
|
||||
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting radio stations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for radio stations globally (across all providers like TuneIn)
|
||||
Future<List<MediaItem>> searchRadioStations(String query, {int limit = 25}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/search',
|
||||
args: {
|
||||
'search_query': query,
|
||||
'media_types': ['radio'],
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
final result = response['result'] as Map<String, dynamic>?;
|
||||
if (result == null) return [];
|
||||
|
||||
// Radio results might be under 'radios' or 'radio' key
|
||||
final radios = (result['radios'] as List<dynamic>?) ??
|
||||
(result['radio'] as List<dynamic>?) ??
|
||||
[];
|
||||
|
||||
return radios
|
||||
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error searching radio stations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============ PODCASTS ============
|
||||
|
||||
/// Get all podcasts from the library
|
||||
Future<List<MediaItem>> getPodcasts({
|
||||
int? limit,
|
||||
int? offset,
|
||||
bool? favoriteOnly,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/podcasts/library_items',
|
||||
args: {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
},
|
||||
);
|
||||
|
||||
final items = response['items'] as List<dynamic>? ?? response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
|
||||
return items
|
||||
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('Error getting podcasts: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get episodes for a specific podcast
|
||||
Future<List<MediaItem>> getPodcastEpisodes(String podcastId, {
|
||||
String? provider,
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/podcasts/podcast_episodes',
|
||||
args: {
|
||||
'item_id': podcastId,
|
||||
if (provider != null) 'provider_instance_id_or_domain': provider,
|
||||
},
|
||||
);
|
||||
|
||||
final episodes = response['result'] as List<dynamic>?;
|
||||
if (episodes != null && episodes.isNotEmpty) {
|
||||
return episodes
|
||||
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
_logger.log('Error getting podcast episodes: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get best available image URL for a podcast
|
||||
/// Falls back to first episode's image if podcast image is unavailable or low quality
|
||||
Future<String?> getPodcastCoverUrl(MediaItem podcast, {int size = 1024}) async {
|
||||
// First try the podcast's own image
|
||||
final podcastImage = getImageUrl(podcast, size: size);
|
||||
|
||||
// Check if podcast has images in metadata
|
||||
final images = podcast.metadata?['images'] as List<dynamic>?;
|
||||
if (images != null && images.isNotEmpty) {
|
||||
// Podcast has images, use them
|
||||
return podcastImage;
|
||||
}
|
||||
|
||||
// No podcast image, try to get first episode's image
|
||||
try {
|
||||
final episodes = await getPodcastEpisodes(
|
||||
podcast.itemId,
|
||||
provider: podcast.provider,
|
||||
);
|
||||
|
||||
if (episodes.isNotEmpty) {
|
||||
final firstEpisode = episodes.first;
|
||||
final episodeImage = getImageUrl(firstEpisode, size: size);
|
||||
if (episodeImage != null) {
|
||||
return episodeImage;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall through to return podcast image
|
||||
}
|
||||
|
||||
// Fall back to podcast image even if it might be low quality
|
||||
return podcastImage;
|
||||
}
|
||||
|
||||
/// Parse browse results into MediaItem episodes
|
||||
List<MediaItem> _parseBrowseEpisodes(List<dynamic> items) {
|
||||
final episodes = <MediaItem>[];
|
||||
for (final item in items) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
final name = item['name'] as String? ??
|
||||
item['label'] as String? ??
|
||||
'Episode';
|
||||
final uri = item['uri'] as String? ?? item['path'] as String?;
|
||||
final itemId = item['item_id'] as String? ?? uri ?? '';
|
||||
final provider = item['provider'] as String? ?? 'library';
|
||||
|
||||
// Parse media type from string
|
||||
final mediaTypeStr = item['media_type'] as String?;
|
||||
MediaType mediaType = MediaType.track;
|
||||
if (mediaTypeStr != null) {
|
||||
mediaType = MediaType.values.firstWhere(
|
||||
(e) => e.name == mediaTypeStr || e.name == mediaTypeStr.toLowerCase(),
|
||||
orElse: () => MediaType.track,
|
||||
);
|
||||
}
|
||||
|
||||
// Get duration if available
|
||||
Duration? duration;
|
||||
final durationVal = item['duration'];
|
||||
if (durationVal is int) {
|
||||
duration = Duration(seconds: durationVal);
|
||||
} else if (durationVal is double) {
|
||||
duration = Duration(seconds: durationVal.toInt());
|
||||
}
|
||||
|
||||
episodes.add(MediaItem(
|
||||
itemId: itemId,
|
||||
provider: provider,
|
||||
name: name,
|
||||
uri: uri,
|
||||
metadata: item['metadata'] as Map<String, dynamic>?,
|
||||
mediaType: mediaType,
|
||||
duration: duration,
|
||||
));
|
||||
}
|
||||
}
|
||||
return episodes;
|
||||
}
|
||||
|
||||
/// Play a podcast episode
|
||||
Future<void> playPodcastEpisode(String playerId, MediaItem episode) async {
|
||||
try {
|
||||
// Try episode.uri first, then construct from itemId
|
||||
String uri;
|
||||
if (episode.uri != null && episode.uri!.isNotEmpty) {
|
||||
uri = episode.uri!;
|
||||
} else {
|
||||
// Construct URI based on media type
|
||||
uri = 'library://podcast_episode/${episode.itemId}';
|
||||
}
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: {
|
||||
'queue_id': playerId,
|
||||
'media': [uri],
|
||||
'option': 'replace',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error playing podcast episode: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Audiobook>> getAudiobooks({
|
||||
int? limit,
|
||||
int? offset,
|
||||
@@ -511,7 +726,6 @@ class MusicAssistantAPI {
|
||||
|
||||
// If specific libraries are enabled, use browse API for filtering
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
_logger.log('📚 Using browse API for library filtering (${enabledLibraries.length} libraries enabled)');
|
||||
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
|
||||
@@ -524,8 +738,6 @@ class MusicAssistantAPI {
|
||||
if (authorId != null) 'author_id': authorId,
|
||||
};
|
||||
|
||||
_logger.log('📚 Calling music/audiobooks/library_items with args: $args');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'music/audiobooks/library_items',
|
||||
args: args,
|
||||
@@ -533,65 +745,21 @@ class MusicAssistantAPI {
|
||||
|
||||
// Check for error in response
|
||||
if (response.containsKey('error_code')) {
|
||||
_logger.log('📚 ERROR: ${response['error_code']} - ${response['details']}');
|
||||
return [];
|
||||
}
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
_logger.log('📚 Audiobooks: result is null');
|
||||
return [];
|
||||
}
|
||||
|
||||
_logger.log('📚 Audiobooks: found ${items.length} items from API');
|
||||
|
||||
// Log first item's structure including metadata to find chapters/series
|
||||
if (items.isNotEmpty) {
|
||||
final firstItem = items.first as Map<String, dynamic>;
|
||||
_logger.log('📚 First item keys: ${firstItem.keys.toList()}');
|
||||
_logger.log('📚 First item name: ${firstItem['name']}, media_type: ${firstItem['media_type']}');
|
||||
|
||||
// Log metadata contents - this is where chapters/series info likely lives
|
||||
if (firstItem.containsKey('metadata')) {
|
||||
final metadata = firstItem['metadata'] as Map<String, dynamic>?;
|
||||
if (metadata != null) {
|
||||
_logger.log('📚 METADATA keys: ${metadata.keys.toList()}');
|
||||
for (final key in metadata.keys) {
|
||||
final value = metadata[key];
|
||||
if (value is List) {
|
||||
_logger.log('📚 metadata[$key] (List, ${value.length}): ${value.take(2)}');
|
||||
} else if (value is Map) {
|
||||
_logger.log('📚 metadata[$key] (Map): ${(value as Map).keys.toList()}');
|
||||
} else if (value != null) {
|
||||
_logger.log('📚 metadata[$key]: $value');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for chapters at top level too
|
||||
if (firstItem.containsKey('chapters')) {
|
||||
_logger.log('📚 CHAPTERS at top level: ${firstItem['chapters']}');
|
||||
}
|
||||
}
|
||||
if (items == null) return [];
|
||||
|
||||
final audiobooks = <Audiobook>[];
|
||||
final parseErrors = <String>[];
|
||||
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
try {
|
||||
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
|
||||
audiobooks.add(book);
|
||||
} catch (e) {
|
||||
parseErrors.add('Item $i: $e');
|
||||
// Skip items that fail to parse
|
||||
}
|
||||
}
|
||||
|
||||
if (parseErrors.isNotEmpty) {
|
||||
_logger.log('📚 Parse errors (${parseErrors.length}): ${parseErrors.take(3).join(", ")}');
|
||||
}
|
||||
|
||||
_logger.log('📚 Successfully parsed ${audiobooks.length}/${items.length} audiobooks');
|
||||
return audiobooks;
|
||||
} catch (e, stack) {
|
||||
_logger.log('Error getting audiobooks: $e');
|
||||
@@ -691,6 +859,26 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Play a radio station
|
||||
Future<void> playRadioStation(String playerId, MediaItem station) async {
|
||||
try {
|
||||
final uri = station.uri ?? 'library://radio/${station.itemId}';
|
||||
_logger.log('📻 Playing radio station: ${station.name} with URI: $uri');
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: {
|
||||
'queue_id': playerId,
|
||||
'media': [uri],
|
||||
'option': 'replace',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error playing radio station: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get full audiobook details including chapters
|
||||
Future<Audiobook?> getAudiobookDetails(String provider, String itemId) async {
|
||||
try {
|
||||
@@ -1051,6 +1239,7 @@ class MusicAssistantAPI {
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
_logger.log('📋 recently_played_items returned ${items?.length ?? 0} items');
|
||||
if (items == null || items.isEmpty) {
|
||||
_logger.log('⚠️ No recently played tracks found');
|
||||
return [];
|
||||
@@ -1060,6 +1249,8 @@ class MusicAssistantAPI {
|
||||
final trackUris = <String>[];
|
||||
for (final item in items) {
|
||||
final trackUri = (item as Map<String, dynamic>)['uri'] as String?;
|
||||
final name = (item as Map<String, dynamic>)['name'] as String?;
|
||||
_logger.log(' 📀 Track: $name');
|
||||
if (trackUri != null) {
|
||||
trackUris.add(trackUri);
|
||||
}
|
||||
@@ -1089,12 +1280,17 @@ class MusicAssistantAPI {
|
||||
|
||||
try {
|
||||
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
||||
final trackName = fullTrack?['name'] as String?;
|
||||
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
||||
final albumUri = albumData?['uri'] as String?;
|
||||
final albumName = albumData?['name'] as String?;
|
||||
|
||||
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
|
||||
seenAlbumUris.add(albumUri);
|
||||
albumUris.add(albumUri);
|
||||
_logger.log(' 📀 Found album: $albumName from track: $trackName');
|
||||
} else if (albumUri == null) {
|
||||
_logger.log(' ⚠️ Track "$trackName" has no album');
|
||||
}
|
||||
} catch (_) {
|
||||
continue;
|
||||
@@ -1363,20 +1559,38 @@ class MusicAssistantAPI {
|
||||
/// Remove item from favorites
|
||||
/// Requires the library_item_id (the numeric ID in the MA library)
|
||||
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
|
||||
try {
|
||||
_logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId');
|
||||
await _sendCommand(
|
||||
'music/favorites/remove_item',
|
||||
args: {
|
||||
'media_type': mediaType,
|
||||
'library_item_id': libraryItemId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await _sendCommand(
|
||||
'music/favorites/remove_item',
|
||||
args: {
|
||||
'media_type': mediaType,
|
||||
'library_item_id': libraryItemId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error removing from favorites: $e');
|
||||
rethrow;
|
||||
}
|
||||
// Library
|
||||
/// Add item to library using URI format
|
||||
/// The item parameter should be a URI like "spotify://artist/4tZwfgrHOc3mvqYlEYSvVi"
|
||||
Future<void> addItemToLibrary(String mediaType, String itemId, String provider) async {
|
||||
final uri = '$provider://$mediaType/$itemId';
|
||||
await _sendCommand(
|
||||
'music/library/add_item',
|
||||
args: {
|
||||
'item': uri,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove item from library
|
||||
/// Requires the library_item_id (the numeric ID in the MA library)
|
||||
Future<void> removeItemFromLibrary(String mediaType, int libraryItemId) async {
|
||||
await _sendCommand(
|
||||
'music/library/remove_item',
|
||||
args: {
|
||||
'media_type': mediaType,
|
||||
'library_item_id': libraryItemId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Search
|
||||
@@ -1399,7 +1613,7 @@ class MusicAssistantAPI {
|
||||
|
||||
final result = searchResponse['result'] as Map<String, dynamic>?;
|
||||
if (result == null) {
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': []};
|
||||
}
|
||||
|
||||
// Parse results from the search response
|
||||
@@ -1428,6 +1642,16 @@ class MusicAssistantAPI {
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final radios = (result['radios'] as List<dynamic>?)
|
||||
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final podcasts = (result['podcasts'] as List<dynamic>?)
|
||||
?.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
// Deduplicate results by name
|
||||
// MA returns both library items and provider items for the same content
|
||||
// Prefer library items (provider='library') as they have all provider mappings
|
||||
@@ -1437,18 +1661,55 @@ class MusicAssistantAPI {
|
||||
'tracks': _deduplicateResults(tracks),
|
||||
'playlists': _deduplicateResults(playlists),
|
||||
'audiobooks': _deduplicateResults(audiobooks),
|
||||
'radios': _deduplicateResults(radios),
|
||||
'podcasts': _deduplicateResults(podcasts),
|
||||
};
|
||||
} catch (e) {
|
||||
_logger.log('Error searching: $e');
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
|
||||
}
|
||||
},
|
||||
).catchError((e) {
|
||||
_logger.log('Error searching after retries: $e');
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': []};
|
||||
return <String, List<MediaItem>>{'artists': [], 'albums': [], 'tracks': [], 'playlists': [], 'audiobooks': [], 'radios': [], 'podcasts': []};
|
||||
});
|
||||
}
|
||||
|
||||
/// Search for podcasts globally (across all providers)
|
||||
Future<List<MediaItem>> searchPodcasts(String query, {int limit = 25}) async {
|
||||
try {
|
||||
_logger.log('🎙️ Searching podcasts for: $query');
|
||||
final response = await _sendCommand(
|
||||
'music/search',
|
||||
args: {
|
||||
'search_query': query,
|
||||
'media_types': ['podcast'],
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
final result = response['result'] as Map<String, dynamic>?;
|
||||
if (result == null) {
|
||||
_logger.log('🎙️ Podcast search: no result');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Podcast results might be under 'podcasts' or 'podcast' key
|
||||
final podcasts = (result['podcasts'] as List<dynamic>?) ??
|
||||
(result['podcast'] as List<dynamic>?) ??
|
||||
[];
|
||||
|
||||
_logger.log('🎙️ Podcast search found ${podcasts.length} results');
|
||||
|
||||
return podcasts
|
||||
.map((item) => MediaItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Error searching podcasts: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Deduplicate search results by name (case-insensitive)
|
||||
/// Prefers library items (provider='library') over provider-specific items
|
||||
/// since library items have complete provider mappings
|
||||
@@ -1634,6 +1895,86 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Play album on player
|
||||
Future<void> playAlbum(String playerId, Album album) async {
|
||||
try {
|
||||
final uri = album.uri ?? 'library://album/${album.itemId}';
|
||||
_logger.log('💿 Playing album: ${album.name} with URI: $uri');
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: {
|
||||
'queue_id': playerId,
|
||||
'media': [uri],
|
||||
'option': 'replace',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error playing album: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add album tracks to queue
|
||||
Future<void> addAlbumToQueue(String playerId, Album album) async {
|
||||
try {
|
||||
final uri = album.uri ?? 'library://album/${album.itemId}';
|
||||
_logger.log('💿 Adding album to queue: ${album.name} with URI: $uri');
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: {
|
||||
'queue_id': playerId,
|
||||
'media': [uri],
|
||||
'option': 'add',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error adding album to queue: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add artist tracks to queue
|
||||
Future<void> addArtistToQueue(String playerId, Artist artist) async {
|
||||
try {
|
||||
final uri = artist.uri ?? 'library://artist/${artist.itemId}';
|
||||
_logger.log('🎤 Adding artist to queue: ${artist.name} with URI: $uri');
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: {
|
||||
'queue_id': playerId,
|
||||
'media': [uri],
|
||||
'option': 'add',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error adding artist to queue: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Play playlist on player
|
||||
Future<void> playPlaylist(String playerId, Playlist playlist) async {
|
||||
try {
|
||||
final uri = playlist.uri ?? 'library://playlist/${playlist.itemId}';
|
||||
_logger.log('📝 Playing playlist: ${playlist.name} with URI: $uri');
|
||||
|
||||
await _sendCommand(
|
||||
'player_queues/play_media',
|
||||
args: {
|
||||
'queue_id': playerId,
|
||||
'media': [uri],
|
||||
'option': 'replace',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error playing playlist: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Play multiple tracks via queue
|
||||
/// If clearQueue is true, replaces the queue (default behavior)
|
||||
/// If startIndex is provided, only tracks from that index onwards will be queued
|
||||
@@ -2112,6 +2453,71 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an item from the queue
|
||||
Future<void> queueCommandDeleteItem(String queueId, String itemId) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'player_queues/delete_item',
|
||||
args: {
|
||||
'queue_id': queueId,
|
||||
'item_id_or_index': itemId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error deleting queue item: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move an item in the queue by a relative position shift
|
||||
/// pos_shift > 0: move down, pos_shift < 0: move up, pos_shift = 0: move to next
|
||||
Future<void> queueCommandMoveItem(String queueId, String itemId, int posShift) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'player_queues/move_item',
|
||||
args: {
|
||||
'queue_id': queueId,
|
||||
'queue_item_id': itemId,
|
||||
'pos_shift': posShift,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error moving queue item: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Play a specific item in the queue by its queue_item_id
|
||||
Future<void> queueCommandPlayIndex(String queueId, String queueItemId) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'player_queues/play_index',
|
||||
args: {
|
||||
'queue_id': queueId,
|
||||
'index': queueItemId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error playing queue item: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all items from the queue
|
||||
Future<void> queueCommandClear(String queueId) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'player_queues/clear',
|
||||
args: {
|
||||
'queue_id': queueId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error clearing queue: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BUILT-IN PLAYER MANAGEMENT
|
||||
// ============================================================================
|
||||
@@ -2139,6 +2545,22 @@ class MusicAssistantAPI {
|
||||
return _eventStreams['player_added']!.stream;
|
||||
}
|
||||
|
||||
/// Stream of media_item_added events (for refreshing library when items are added)
|
||||
Stream<Map<String, dynamic>> get mediaItemAddedEvents {
|
||||
if (!_eventStreams.containsKey('media_item_added')) {
|
||||
_eventStreams['media_item_added'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||
}
|
||||
return _eventStreams['media_item_added']!.stream;
|
||||
}
|
||||
|
||||
/// Stream of media_item_deleted events (for refreshing library when items are removed)
|
||||
Stream<Map<String, dynamic>> get mediaItemDeletedEvents {
|
||||
if (!_eventStreams.containsKey('media_item_deleted')) {
|
||||
_eventStreams['media_item_deleted'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||
}
|
||||
return _eventStreams['media_item_deleted']!.stream;
|
||||
}
|
||||
|
||||
/// Register this device as a player with retry logic
|
||||
/// CRITICAL: This creates a player config in MA's settings.json
|
||||
/// The server expects: player_id and player_name
|
||||
@@ -2540,6 +2962,7 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error parsing stream URI "$uri": $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2619,6 +3042,57 @@ class MusicAssistantAPI {
|
||||
return '$baseUrl/imageproxy?provider=${Uri.encodeComponent(provider ?? "")}&size=$size&fmt=jpeg&path=${Uri.encodeComponent(imagePath)}';
|
||||
}
|
||||
|
||||
// ==================== iTunes Artwork Lookup ====================
|
||||
|
||||
/// Search iTunes for a podcast and return high-res artwork URL
|
||||
/// Returns null if not found or on error
|
||||
Future<String?> getITunesPodcastArtwork(String podcastName) async {
|
||||
try {
|
||||
final searchTerm = Uri.encodeComponent(podcastName);
|
||||
final url = 'https://itunes.apple.com/search?term=$searchTerm&entity=podcast&limit=1';
|
||||
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 10);
|
||||
|
||||
final request = await client.getUrl(Uri.parse(url));
|
||||
final response = await request.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
_logger.log('🎨 iTunes search failed for "$podcastName": ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
final results = json['results'] as List<dynamic>?;
|
||||
if (results == null || results.isEmpty) {
|
||||
_logger.log('🎨 No iTunes results for "$podcastName"');
|
||||
return null;
|
||||
}
|
||||
|
||||
final podcast = results.first as Map<String, dynamic>;
|
||||
var artworkUrl = podcast['artworkUrl600'] as String?;
|
||||
|
||||
if (artworkUrl == null) {
|
||||
artworkUrl = podcast['artworkUrl100'] as String?;
|
||||
}
|
||||
|
||||
if (artworkUrl != null) {
|
||||
// Use 800x800 - good quality without being excessive (was 1400x1400)
|
||||
artworkUrl = artworkUrl
|
||||
.replaceAll('600x600', '800x800')
|
||||
.replaceAll('100x100', '800x800');
|
||||
_logger.log('🎨 Found iTunes artwork for "$podcastName": $artworkUrl');
|
||||
}
|
||||
|
||||
return artworkUrl;
|
||||
} catch (e) {
|
||||
_logger.log('🎨 iTunes lookup error for "$podcastName": $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Authentication Methods ====================
|
||||
|
||||
/// Authenticate the WebSocket session with a token
|
||||
@@ -2908,6 +3382,17 @@ class MusicAssistantAPI {
|
||||
_updateConnectionState(MAConnectionState.disconnected);
|
||||
await _channel?.sink.close();
|
||||
_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();
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ class PcmAudioPlayer {
|
||||
// Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response
|
||||
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
|
||||
int _framesPlayed = 0;
|
||||
int _bytesPlayed = 0;
|
||||
@@ -201,6 +206,16 @@ class PcmAudioPlayer {
|
||||
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
|
||||
void _onAudioData(Uint8List audioData) {
|
||||
// Ignore data if in error state
|
||||
@@ -215,7 +230,7 @@ class PcmAudioPlayer {
|
||||
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
||||
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
||||
_isAutoRecovering = true;
|
||||
_audioBuffer.add(audioData);
|
||||
_addToBuffer(audioData);
|
||||
|
||||
// Trigger async recovery
|
||||
_autoRecoverFromPause();
|
||||
@@ -224,7 +239,7 @@ class PcmAudioPlayer {
|
||||
|
||||
// If already recovering or in transitional state, buffer the data
|
||||
if (_isAutoRecovering) {
|
||||
_audioBuffer.add(audioData);
|
||||
_addToBuffer(audioData);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -232,8 +247,8 @@ class PcmAudioPlayer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to buffer
|
||||
_audioBuffer.add(audioData);
|
||||
// Add to buffer with overflow protection
|
||||
_addToBuffer(audioData);
|
||||
|
||||
// Start playback if not already started
|
||||
if (!_isStarted && _state == PcmPlayerState.ready) {
|
||||
|
||||
@@ -24,6 +24,10 @@ class PositionTracker {
|
||||
double _anchorPosition = 0.0; // Position in seconds at anchor time
|
||||
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
|
||||
Timer? _interpolationTimer;
|
||||
|
||||
@@ -43,7 +47,13 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Cap at duration to prevent overflow
|
||||
@@ -61,7 +71,12 @@ class PositionTracker {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Cap at duration
|
||||
|
||||
@@ -151,7 +151,10 @@ class RecentlyPlayedService {
|
||||
if (item.metadata != null) {
|
||||
try {
|
||||
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)
|
||||
@@ -203,7 +206,10 @@ class RecentlyPlayedService {
|
||||
if (item.metadata != null) {
|
||||
try {
|
||||
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(
|
||||
|
||||
@@ -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+)
|
||||
String? _authToken;
|
||||
|
||||
// Connection deduplication guard - prevents multiple concurrent auth attempts
|
||||
Completer<bool>? _connectionInProgress;
|
||||
|
||||
SendspinService(this.serverUrl);
|
||||
|
||||
/// Set the MA auth token for proxy authentication
|
||||
@@ -100,6 +103,13 @@ class SendspinService {
|
||||
if (_isDisposed) return false;
|
||||
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);
|
||||
|
||||
try {
|
||||
@@ -126,13 +136,19 @@ class SendspinService {
|
||||
if (!connected) {
|
||||
_logger.log('Sendspin: External proxy connection failed');
|
||||
_updateState(SendspinConnectionState.error);
|
||||
_connectionInProgress?.complete(false);
|
||||
_connectionInProgress = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
_connectionInProgress?.complete(true);
|
||||
_connectionInProgress = null;
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Connection error: $e');
|
||||
_updateState(SendspinConnectionState.error);
|
||||
_connectionInProgress?.complete(false);
|
||||
_connectionInProgress = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -142,6 +158,13 @@ class SendspinService {
|
||||
if (_isDisposed) return false;
|
||||
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);
|
||||
|
||||
try {
|
||||
@@ -154,13 +177,19 @@ class SendspinService {
|
||||
|
||||
if (!connected) {
|
||||
_updateState(SendspinConnectionState.error);
|
||||
_connectionInProgress?.complete(false);
|
||||
_connectionInProgress = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
_connectionInProgress?.complete(true);
|
||||
_connectionInProgress = null;
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Connection error: $e');
|
||||
_updateState(SendspinConnectionState.error);
|
||||
_connectionInProgress?.complete(false);
|
||||
_connectionInProgress = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'dart:convert';
|
||||
import 'device_id_service.dart';
|
||||
import 'secure_storage_service.dart';
|
||||
|
||||
class SettingsService {
|
||||
static const String _keyServerUrl = 'server_url';
|
||||
@@ -34,6 +35,9 @@ class SettingsService {
|
||||
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
||||
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
||||
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
||||
static const String _keyShowFavoritePlaylists = 'show_favorite_playlists';
|
||||
static const String _keyShowFavoriteRadioStations = 'show_favorite_radio_stations';
|
||||
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
|
||||
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
|
||||
|
||||
// Default row order
|
||||
@@ -47,6 +51,8 @@ class SettingsService {
|
||||
'favorite-albums',
|
||||
'favorite-artists',
|
||||
'favorite-tracks',
|
||||
'favorite-playlists',
|
||||
'favorite-radio-stations',
|
||||
];
|
||||
|
||||
// View Mode Settings
|
||||
@@ -65,6 +71,8 @@ class SettingsService {
|
||||
static const String _keyLibrarySeriesViewMode = 'library_series_view_mode'; // 'grid2', 'grid3', 'list'
|
||||
static const String _keySeriesAudiobooksSortOrder = 'series_audiobooks_sort_order'; // 'alpha' or 'year'
|
||||
static const String _keySeriesAudiobooksViewMode = 'series_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
|
||||
static const String _keyLibraryRadioViewMode = 'library_radio_view_mode'; // 'grid2', 'grid3', 'list'
|
||||
static const String _keyLibraryPodcastsViewMode = 'library_podcasts_view_mode'; // 'grid2', 'grid3', 'list'
|
||||
|
||||
// Audiobookshelf Direct Integration Settings
|
||||
static const String _keyAbsServerUrl = 'abs_server_url';
|
||||
@@ -80,6 +88,12 @@ class SettingsService {
|
||||
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
||||
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
||||
|
||||
// Volume Precision Mode Settings
|
||||
static const String _keyVolumePrecisionMode = 'volume_precision_mode'; // Enable hold-to-precision volume control
|
||||
|
||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
@@ -127,66 +141,44 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Get authentication token for stream requests
|
||||
// Get authentication token for stream requests (securely stored)
|
||||
static Future<String?> getAuthToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyAuthToken);
|
||||
return await SecureStorageService.getAuthToken();
|
||||
}
|
||||
|
||||
// Set authentication token for stream requests
|
||||
// Set authentication token for stream requests (securely stored)
|
||||
static Future<void> setAuthToken(String? token) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (token == null || token.isEmpty) {
|
||||
await prefs.remove(_keyAuthToken);
|
||||
} else {
|
||||
await prefs.setString(_keyAuthToken, token);
|
||||
}
|
||||
await SecureStorageService.setAuthToken(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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyMaAuthToken);
|
||||
return await SecureStorageService.getMaAuthToken();
|
||||
}
|
||||
|
||||
// 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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (token == null || token.isEmpty) {
|
||||
await prefs.remove(_keyMaAuthToken);
|
||||
} else {
|
||||
await prefs.setString(_keyMaAuthToken, token);
|
||||
}
|
||||
await SecureStorageService.setMaAuthToken(token);
|
||||
}
|
||||
|
||||
// Clear Music Assistant native auth token
|
||||
static Future<void> clearMaAuthToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keyMaAuthToken);
|
||||
await SecureStorageService.clearMaAuthToken();
|
||||
}
|
||||
|
||||
// Get authentication credentials (serialized auth strategy credentials)
|
||||
// Get authentication credentials (serialized auth strategy credentials, securely stored)
|
||||
static Future<Map<String, dynamic>?> getAuthCredentials() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = prefs.getString(_keyAuthCredentials);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return await SecureStorageService.getAuthCredentials();
|
||||
}
|
||||
|
||||
// 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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyAuthCredentials, jsonEncode(credentials));
|
||||
await SecureStorageService.setAuthCredentials(credentials);
|
||||
}
|
||||
|
||||
// Clear authentication credentials
|
||||
static Future<void> clearAuthCredentials() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keyAuthCredentials);
|
||||
await SecureStorageService.clearAuthCredentials();
|
||||
}
|
||||
|
||||
// Get username for authentication
|
||||
@@ -205,20 +197,14 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Get password for authentication
|
||||
// Get password for authentication (securely stored)
|
||||
static Future<String?> getPassword() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyPassword);
|
||||
return await SecureStorageService.getPassword();
|
||||
}
|
||||
|
||||
// Set password for authentication
|
||||
// Set password for authentication (securely stored)
|
||||
static Future<void> setPassword(String? password) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (password == null || password.isEmpty) {
|
||||
await prefs.remove(_keyPassword);
|
||||
} else {
|
||||
await prefs.setString(_keyPassword, password);
|
||||
}
|
||||
await SecureStorageService.setPassword(password);
|
||||
}
|
||||
|
||||
// Get built-in player ID (persistent UUID for this device)
|
||||
@@ -409,6 +395,13 @@ class SettingsService {
|
||||
static Future<void> clearSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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)
|
||||
@@ -504,6 +497,37 @@ class SettingsService {
|
||||
await prefs.setBool(_keyShowFavoriteTracks, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowFavoritePlaylists() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoritePlaylists) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowFavoritePlaylists(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoritePlaylists, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowFavoriteRadioStations() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoriteRadioStations) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowFavoriteRadioStations(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoriteRadioStations, show);
|
||||
}
|
||||
|
||||
// Library Artists Filter - show only artists that have albums in library
|
||||
static Future<bool> getShowOnlyArtistsWithAlbums() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowOnlyArtistsWithAlbums) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowOnlyArtistsWithAlbums(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowOnlyArtistsWithAlbums, show);
|
||||
}
|
||||
|
||||
// Home Row Order
|
||||
static Future<List<String>> getHomeRowOrder() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -641,6 +665,26 @@ class SettingsService {
|
||||
await prefs.setString(_keyLibrarySeriesViewMode, mode);
|
||||
}
|
||||
|
||||
static Future<String> getLibraryRadioViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyLibraryRadioViewMode) ?? 'list';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryRadioViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyLibraryRadioViewMode, mode);
|
||||
}
|
||||
|
||||
static Future<String> getLibraryPodcastsViewMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyLibraryPodcastsViewMode) ?? 'grid2';
|
||||
}
|
||||
|
||||
static Future<void> setLibraryPodcastsViewMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyLibraryPodcastsViewMode, mode);
|
||||
}
|
||||
|
||||
// View Mode Settings - Series Audiobooks (series detail screen)
|
||||
static Future<String> getSeriesAudiobooksSortOrder() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -674,13 +718,11 @@ class SettingsService {
|
||||
}
|
||||
|
||||
static Future<String?> getAbsApiToken() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyAbsApiToken);
|
||||
return await SecureStorageService.getAbsApiToken();
|
||||
}
|
||||
|
||||
static Future<void> setAbsApiToken(String token) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyAbsApiToken, token);
|
||||
await SecureStorageService.setAbsApiToken(token);
|
||||
}
|
||||
|
||||
static Future<bool> getAbsEnabled() async {
|
||||
@@ -696,8 +738,8 @@ class SettingsService {
|
||||
static Future<void> clearAbsSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keyAbsServerUrl);
|
||||
await prefs.remove(_keyAbsApiToken);
|
||||
await prefs.remove(_keyAbsEnabled);
|
||||
await SecureStorageService.setAbsApiToken(null); // Clear from secure storage
|
||||
}
|
||||
|
||||
// Audiobookshelf Library Settings (via MA browse API)
|
||||
@@ -808,4 +850,49 @@ class SettingsService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyHasCompletedOnboarding, completed);
|
||||
}
|
||||
|
||||
// Volume Precision Mode Settings
|
||||
|
||||
/// Get whether volume precision mode is enabled (default: true)
|
||||
/// When enabled, holding still while adjusting volume enters precision mode
|
||||
/// for fine-grained control (10x more precise)
|
||||
static Future<bool> getVolumePrecisionMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
|
||||
}
|
||||
|
||||
/// Set whether volume precision mode is enabled
|
||||
static Future<void> setVolumePrecisionMode(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyVolumePrecisionMode, enabled);
|
||||
}
|
||||
|
||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||
// Stored as JSON: {"podcastId": "itunesUrl", ...}
|
||||
|
||||
/// Get cached podcast cover URLs (iTunes high-res)
|
||||
static Future<Map<String, String>> getPodcastCoverCache() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = prefs.getString(_keyPodcastCoverCache);
|
||||
if (json == null) return {};
|
||||
try {
|
||||
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||
return decoded.map((key, value) => MapEntry(key, value as String));
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Save podcast cover cache
|
||||
static Future<void> setPodcastCoverCache(Map<String, String> cache) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keyPodcastCoverCache, jsonEncode(cache));
|
||||
}
|
||||
|
||||
/// Add a single podcast cover to cache (for incremental updates)
|
||||
static Future<void> addPodcastCoverToCache(String podcastId, String url) async {
|
||||
final cache = await getPodcastCoverCache();
|
||||
cache[podcastId] = url;
|
||||
await setPodcastCoverCache(cache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../models/media_item.dart';
|
||||
import 'database_service.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'music_assistant_api.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
/// Sync status for UI indicators
|
||||
enum SyncStatus {
|
||||
@@ -150,10 +151,14 @@ class SyncService with ChangeNotifier {
|
||||
try {
|
||||
_logger.log('🔄 Starting background library sync...');
|
||||
|
||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
||||
|
||||
// Fetch fresh data from MA API (in parallel for speed)
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000),
|
||||
api.getArtists(limit: 1000),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||
api.getAudiobooks(limit: 1000),
|
||||
api.getPlaylists(limit: 1000),
|
||||
]);
|
||||
@@ -166,6 +171,12 @@ class SyncService with ChangeNotifier {
|
||||
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
|
||||
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
|
||||
|
||||
// Clear old cache before saving fresh data (removes stale items)
|
||||
await _db.clearCacheForType('album');
|
||||
await _db.clearCacheForType('artist');
|
||||
await _db.clearCacheForType('audiobook');
|
||||
await _db.clearCacheForType('playlist');
|
||||
|
||||
// Save to database cache
|
||||
await _saveAlbumsToCache(albums);
|
||||
await _saveArtistsToCache(artists);
|
||||
|
||||
@@ -9,6 +9,7 @@ final _themeLogger = DebugLogger();
|
||||
|
||||
/// Global function to update adaptive colors from an image URL
|
||||
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
|
||||
/// Sets isOnDetailScreen=true so nav bar will also use adaptive colors
|
||||
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
|
||||
if (imageUrl == null || imageUrl.isEmpty) return;
|
||||
|
||||
@@ -16,7 +17,7 @@ Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUr
|
||||
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
|
||||
if (colorSchemes != null && context.mounted) {
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
|
||||
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2, isFromDetailScreen: true);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail - colors will update when track plays
|
||||
@@ -42,6 +43,10 @@ class ThemeProvider extends ChangeNotifier {
|
||||
ColorScheme? _adaptiveLightScheme;
|
||||
ColorScheme? _adaptiveDarkScheme;
|
||||
|
||||
// Track if we're on a detail screen (vs just player-extracted colors)
|
||||
// When true, nav bar should use adaptive colors even when player is collapsed
|
||||
bool _isOnDetailScreen = false;
|
||||
|
||||
ThemeProvider() {
|
||||
_loadSettings();
|
||||
}
|
||||
@@ -55,12 +60,14 @@ class ThemeProvider extends ChangeNotifier {
|
||||
AdaptiveColors? get adaptiveColors => _adaptiveColors;
|
||||
ColorScheme? get adaptiveLightScheme => _adaptiveLightScheme;
|
||||
ColorScheme? get adaptiveDarkScheme => _adaptiveDarkScheme;
|
||||
bool get isOnDetailScreen => _isOnDetailScreen;
|
||||
|
||||
/// Get the current adaptive primary color (for bottom nav highlight, etc.)
|
||||
Color get adaptivePrimaryColor => _adaptiveColors?.primary ?? _customColor;
|
||||
|
||||
/// Get the current adaptive surface color (for bottom nav background, etc.)
|
||||
/// Returns a subtle tinted surface based on the adaptive colors
|
||||
/// NOTE: Prefer getAdaptiveSurfaceColorFor() which respects light/dark mode
|
||||
Color? get adaptiveSurfaceColor {
|
||||
if (_adaptiveColors == null) return null;
|
||||
// Use the miniPlayer color but darkened for a subtle tinted background
|
||||
@@ -68,6 +75,20 @@ class ThemeProvider extends ChangeNotifier {
|
||||
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
|
||||
}
|
||||
|
||||
/// Get adaptive surface color for the given brightness (light/dark mode aware)
|
||||
/// Returns the same color as the expanded player background (scheme.surface)
|
||||
Color? getAdaptiveSurfaceColorFor(Brightness brightness) {
|
||||
// Pick scheme based on current mode
|
||||
final scheme = brightness == Brightness.dark
|
||||
? _adaptiveDarkScheme
|
||||
: _adaptiveLightScheme;
|
||||
|
||||
if (scheme == null) return null;
|
||||
|
||||
// Use surface - same as expanded player background (line 1234 in expandable_player.dart)
|
||||
return scheme.surface;
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final themeModeString = await SettingsService.getThemeMode();
|
||||
_themeMode = _parseThemeMode(themeModeString);
|
||||
@@ -147,10 +168,18 @@ class ThemeProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Update adaptive colors from album art
|
||||
void updateAdaptiveColors(ColorScheme? lightScheme, ColorScheme? darkScheme) {
|
||||
/// [isFromDetailScreen] - if true, this is from navigating to a detail screen
|
||||
/// and the nav bar should use adaptive colors even when player is collapsed
|
||||
void updateAdaptiveColors(ColorScheme? lightScheme, ColorScheme? darkScheme, {bool isFromDetailScreen = false}) {
|
||||
_adaptiveLightScheme = lightScheme;
|
||||
_adaptiveDarkScheme = darkScheme;
|
||||
|
||||
// Only set detail screen flag if explicitly from a detail screen navigation
|
||||
// Player-extracted colors should NOT set this flag
|
||||
if (isFromDetailScreen) {
|
||||
_isOnDetailScreen = true;
|
||||
}
|
||||
|
||||
// Extract AdaptiveColors from the schemes
|
||||
if (darkScheme != null) {
|
||||
_adaptiveColors = AdaptiveColors(
|
||||
@@ -166,11 +195,12 @@ class ThemeProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear adaptive colors (when no track is playing)
|
||||
/// Clear adaptive colors (when navigating back from detail screen or no track playing)
|
||||
void clearAdaptiveColors() {
|
||||
_adaptiveColors = null;
|
||||
_adaptiveLightScheme = null;
|
||||
_adaptiveDarkScheme = null;
|
||||
_isOnDetailScreen = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Fuzzy string matching using Jaro-Winkler similarity.
|
||||
///
|
||||
/// Jaro-Winkler is optimized for short strings like artist/song names
|
||||
/// and gives extra weight to matching prefixes (common typos occur mid-word).
|
||||
class FuzzyMatcher {
|
||||
/// Calculate Jaro-Winkler similarity between two strings.
|
||||
///
|
||||
/// Returns a value between 0.0 (no similarity) and 1.0 (exact match).
|
||||
///
|
||||
/// Jaro-Winkler gives bonus weight to strings that match from the beginning,
|
||||
/// which is useful for handling typos like "Beetles" vs "Beatles".
|
||||
double jaroWinklerSimilarity(String s1, String s2) {
|
||||
if (s1 == s2) return 1.0;
|
||||
if (s1.isEmpty || s2.isEmpty) return 0.0;
|
||||
|
||||
final jaro = _jaroSimilarity(s1, s2);
|
||||
|
||||
// Winkler modification: boost score for common prefix
|
||||
int prefixLength = 0;
|
||||
const maxPrefix = 4; // Standard Winkler prefix length
|
||||
final minLen = min(s1.length, s2.length);
|
||||
final prefixLimit = min(maxPrefix, minLen);
|
||||
|
||||
for (int i = 0; i < prefixLimit; i++) {
|
||||
if (s1[i] == s2[i]) {
|
||||
prefixLength++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const scalingFactor = 0.1; // Standard Winkler scaling factor
|
||||
return jaro + (prefixLength * scalingFactor * (1 - jaro));
|
||||
}
|
||||
|
||||
/// Calculate base Jaro similarity.
|
||||
double _jaroSimilarity(String s1, String s2) {
|
||||
final s1Len = s1.length;
|
||||
final s2Len = s2.length;
|
||||
|
||||
// Match window: characters within this distance can match
|
||||
final matchWindow = (max(s1Len, s2Len) / 2 - 1).floor();
|
||||
if (matchWindow < 0) return 0.0;
|
||||
|
||||
final s1Matches = List.filled(s1Len, false);
|
||||
final s2Matches = List.filled(s2Len, false);
|
||||
|
||||
int matches = 0;
|
||||
int transpositions = 0;
|
||||
|
||||
// Find matching characters
|
||||
for (int i = 0; i < s1Len; i++) {
|
||||
final start = max(0, i - matchWindow);
|
||||
final end = min(s2Len, i + matchWindow + 1);
|
||||
|
||||
for (int j = start; j < end; j++) {
|
||||
if (s2Matches[j] || s1[i] != s2[j]) continue;
|
||||
s1Matches[i] = true;
|
||||
s2Matches[j] = true;
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches == 0) return 0.0;
|
||||
|
||||
// Count transpositions
|
||||
int k = 0;
|
||||
for (int i = 0; i < s1Len; i++) {
|
||||
if (!s1Matches[i]) continue;
|
||||
while (!s2Matches[k]) {
|
||||
k++;
|
||||
}
|
||||
if (s1[i] != s2[k]) transpositions++;
|
||||
k++;
|
||||
}
|
||||
|
||||
// Jaro similarity formula
|
||||
return (matches / s1Len +
|
||||
matches / s2Len +
|
||||
(matches - transpositions / 2) / matches) /
|
||||
3;
|
||||
}
|
||||
|
||||
/// Find the best match score between query tokens and text tokens.
|
||||
///
|
||||
/// Useful for matching individual words when full string matching fails.
|
||||
/// Returns the highest similarity score found between any token pair.
|
||||
double bestTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||
|
||||
double bestScore = 0.0;
|
||||
|
||||
for (final queryToken in queryTokens) {
|
||||
for (final textToken in textTokens) {
|
||||
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
// Early exit if we find a perfect match
|
||||
if (score == 1.0) return 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore;
|
||||
}
|
||||
|
||||
/// Check if two strings are a fuzzy match within a threshold.
|
||||
bool isFuzzyMatch(String s1, String s2, {double threshold = 0.85}) {
|
||||
return jaroWinklerSimilarity(s1, s2) >= threshold;
|
||||
}
|
||||
|
||||
/// Calculate average token match score.
|
||||
///
|
||||
/// For each query token, finds the best matching text token,
|
||||
/// then averages all best matches. Useful for multi-word queries.
|
||||
double averageTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||
|
||||
double totalScore = 0.0;
|
||||
|
||||
for (final queryToken in queryTokens) {
|
||||
double bestForToken = 0.0;
|
||||
for (final textToken in textTokens) {
|
||||
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||
if (score > bestForToken) {
|
||||
bestForToken = score;
|
||||
}
|
||||
}
|
||||
totalScore += bestForToken;
|
||||
}
|
||||
|
||||
return totalScore / queryTokens.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/// N-gram based string matching for partial matches.
|
||||
///
|
||||
/// Uses character bigrams (2-grams) to find partial string matches
|
||||
/// when exact and fuzzy matching fail.
|
||||
class NgramMatcher {
|
||||
/// Generate character bigrams from text.
|
||||
///
|
||||
/// Example: "hello" -> ["he", "el", "ll", "lo"]
|
||||
///
|
||||
/// For strings shorter than 2 characters, returns the string itself.
|
||||
List<String> generateBigrams(String text) {
|
||||
if (text.length < 2) return text.isNotEmpty ? [text] : [];
|
||||
|
||||
final bigrams = <String>[];
|
||||
for (int i = 0; i < text.length - 1; i++) {
|
||||
bigrams.add(text.substring(i, i + 2));
|
||||
}
|
||||
return bigrams;
|
||||
}
|
||||
|
||||
/// Generate character trigrams from text.
|
||||
///
|
||||
/// Example: "hello" -> ["hel", "ell", "llo"]
|
||||
List<String> generateTrigrams(String text) {
|
||||
if (text.length < 3) return text.isNotEmpty ? [text] : [];
|
||||
|
||||
final trigrams = <String>[];
|
||||
for (int i = 0; i < text.length - 2; i++) {
|
||||
trigrams.add(text.substring(i, i + 3));
|
||||
}
|
||||
return trigrams;
|
||||
}
|
||||
|
||||
/// Calculate bigram similarity using Dice coefficient.
|
||||
///
|
||||
/// Dice coefficient: 2 * |intersection| / (|set1| + |set2|)
|
||||
/// Returns a value between 0.0 and 1.0.
|
||||
double bigramSimilarity(String s1, String s2) {
|
||||
final lower1 = s1.toLowerCase();
|
||||
final lower2 = s2.toLowerCase();
|
||||
|
||||
final bigrams1 = generateBigrams(lower1);
|
||||
final bigrams2 = generateBigrams(lower2);
|
||||
|
||||
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||
|
||||
final set1 = bigrams1.toSet();
|
||||
final set2 = bigrams2.toSet();
|
||||
|
||||
final intersection = set1.intersection(set2).length;
|
||||
|
||||
// Dice coefficient
|
||||
return (2 * intersection) / (set1.length + set2.length);
|
||||
}
|
||||
|
||||
/// Calculate Jaccard similarity between bigram sets.
|
||||
///
|
||||
/// Jaccard: |intersection| / |union|
|
||||
/// More conservative than Dice coefficient.
|
||||
double jaccardSimilarity(String s1, String s2) {
|
||||
final lower1 = s1.toLowerCase();
|
||||
final lower2 = s2.toLowerCase();
|
||||
|
||||
final bigrams1 = generateBigrams(lower1).toSet();
|
||||
final bigrams2 = generateBigrams(lower2).toSet();
|
||||
|
||||
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||
|
||||
final intersection = bigrams1.intersection(bigrams2).length;
|
||||
final union = bigrams1.union(bigrams2).length;
|
||||
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
/// Check if query partially matches text via bigrams.
|
||||
///
|
||||
/// Uses Dice coefficient with configurable threshold.
|
||||
bool hasPartialMatch(String query, String text, {double threshold = 0.5}) {
|
||||
return bigramSimilarity(query, text) >= threshold;
|
||||
}
|
||||
|
||||
/// Calculate containment coefficient.
|
||||
///
|
||||
/// Measures how many of the query's bigrams are in the text.
|
||||
/// Useful when query is expected to be shorter than text.
|
||||
/// Returns |intersection| / |query bigrams|
|
||||
double containmentScore(String query, String text) {
|
||||
final queryBigrams = generateBigrams(query.toLowerCase()).toSet();
|
||||
final textBigrams = generateBigrams(text.toLowerCase()).toSet();
|
||||
|
||||
if (queryBigrams.isEmpty) return 0.0;
|
||||
|
||||
final intersection = queryBigrams.intersection(textBigrams).length;
|
||||
return intersection / queryBigrams.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/// Configuration for search scoring weights and thresholds.
|
||||
///
|
||||
/// All scores are designed to work with the existing sorting system
|
||||
/// where higher scores indicate better matches.
|
||||
class ScoringConfig {
|
||||
// Base scores for match types (primary name matching)
|
||||
final double exactMatch;
|
||||
final double exactMatchNoStopwords;
|
||||
final double startsWithMatch;
|
||||
final double startsWithNoStopwords;
|
||||
final double wordBoundaryMatch;
|
||||
final double wordBoundaryNoStopwords;
|
||||
final double reverseContainsMatch;
|
||||
final double reverseContainsNoStopwords;
|
||||
final double containsMatch;
|
||||
final double containsNoStopwords;
|
||||
final double fuzzyMatchHigh;
|
||||
final double fuzzyMatchMedium;
|
||||
final double ngramMatch;
|
||||
final double baseline;
|
||||
|
||||
// Bonuses (additive)
|
||||
final double libraryBonus;
|
||||
final double favoriteBonus;
|
||||
final double artistFieldExactBonus;
|
||||
final double artistFieldPartialBonus;
|
||||
final double albumFieldBonus;
|
||||
final double authorFieldExactBonus;
|
||||
final double authorFieldPartialBonus;
|
||||
final double narratorFieldBonus;
|
||||
final double creatorFieldExactBonus;
|
||||
final double creatorFieldPartialBonus;
|
||||
final double descriptionBonus;
|
||||
|
||||
// Thresholds
|
||||
final double fuzzyHighThreshold;
|
||||
final double fuzzyMediumThreshold;
|
||||
final double ngramThreshold;
|
||||
|
||||
// Minimum lengths for matching
|
||||
final int minReverseMatchLength;
|
||||
final int minTokenLength;
|
||||
|
||||
const ScoringConfig({
|
||||
// Primary match scores
|
||||
this.exactMatch = 100,
|
||||
this.exactMatchNoStopwords = 95,
|
||||
this.startsWithMatch = 85,
|
||||
this.startsWithNoStopwords = 80,
|
||||
this.wordBoundaryMatch = 70,
|
||||
this.wordBoundaryNoStopwords = 65,
|
||||
this.reverseContainsMatch = 60,
|
||||
this.reverseContainsNoStopwords = 55,
|
||||
this.containsMatch = 50,
|
||||
this.containsNoStopwords = 45,
|
||||
this.fuzzyMatchHigh = 40,
|
||||
this.fuzzyMatchMedium = 35,
|
||||
this.ngramMatch = 25,
|
||||
this.baseline = 20,
|
||||
|
||||
// Bonuses
|
||||
this.libraryBonus = 10,
|
||||
this.favoriteBonus = 5,
|
||||
this.artistFieldExactBonus = 15,
|
||||
this.artistFieldPartialBonus = 8,
|
||||
this.albumFieldBonus = 5,
|
||||
this.authorFieldExactBonus = 15,
|
||||
this.authorFieldPartialBonus = 8,
|
||||
this.narratorFieldBonus = 5,
|
||||
this.creatorFieldExactBonus = 15,
|
||||
this.creatorFieldPartialBonus = 8,
|
||||
this.descriptionBonus = 5,
|
||||
|
||||
// Thresholds
|
||||
this.fuzzyHighThreshold = 0.90,
|
||||
this.fuzzyMediumThreshold = 0.85,
|
||||
this.ngramThreshold = 0.5,
|
||||
|
||||
// Minimum lengths
|
||||
this.minReverseMatchLength = 3,
|
||||
this.minTokenLength = 3,
|
||||
});
|
||||
|
||||
/// Default configuration
|
||||
static const ScoringConfig defaults = ScoringConfig();
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/// Search scoring system for ranking search results.
|
||||
///
|
||||
/// Provides advanced text matching including:
|
||||
/// - Stopword removal ("the Ramones" finds "Ramones")
|
||||
/// - Fuzzy matching (typo tolerance)
|
||||
/// - N-gram matching (partial matches)
|
||||
/// - Reverse matching (query contains result)
|
||||
library;
|
||||
|
||||
export 'scoring_config.dart';
|
||||
export 'text_normalizer.dart';
|
||||
export 'fuzzy_matcher.dart';
|
||||
export 'ngram_matcher.dart';
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import '../../models/media_item.dart';
|
||||
import 'scoring_config.dart';
|
||||
import 'text_normalizer.dart';
|
||||
import 'fuzzy_matcher.dart';
|
||||
import 'ngram_matcher.dart';
|
||||
|
||||
/// Main search scorer that combines all matching strategies.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final scorer = SearchScorer();
|
||||
/// final score = scorer.scoreItem(mediaItem, query);
|
||||
/// ```
|
||||
class SearchScorer {
|
||||
final TextNormalizer _normalizer;
|
||||
final FuzzyMatcher _fuzzyMatcher;
|
||||
final NgramMatcher _ngramMatcher;
|
||||
final ScoringConfig _config;
|
||||
|
||||
// Cache for normalized query (reused across items in same search)
|
||||
String? _cachedQueryString;
|
||||
NormalizedQuery? _cachedQuery;
|
||||
|
||||
SearchScorer({ScoringConfig? config})
|
||||
: _normalizer = TextNormalizer(),
|
||||
_fuzzyMatcher = FuzzyMatcher(),
|
||||
_ngramMatcher = NgramMatcher(),
|
||||
_config = config ?? ScoringConfig.defaults;
|
||||
|
||||
/// Access to the normalizer for external use
|
||||
TextNormalizer get normalizer => _normalizer;
|
||||
|
||||
/// Clear the query cache (call when starting a new search)
|
||||
void clearCache() {
|
||||
_cachedQueryString = null;
|
||||
_cachedQuery = null;
|
||||
}
|
||||
|
||||
/// Score a media item against a search query.
|
||||
///
|
||||
/// Returns a score where higher values indicate better matches.
|
||||
/// Score components:
|
||||
/// - Primary name matching (0-100)
|
||||
/// - Secondary field bonuses (0-20)
|
||||
/// - Library/favorite bonuses (0-15)
|
||||
double scoreItem(MediaItem item, String query) {
|
||||
// Use cached normalized query if available
|
||||
if (_cachedQueryString != query) {
|
||||
_cachedQueryString = query;
|
||||
_cachedQuery = _normalizer.normalizeQuery(query);
|
||||
}
|
||||
|
||||
final nq = _cachedQuery!;
|
||||
if (nq.isEmpty) return 0;
|
||||
|
||||
final nameLower = item.name.toLowerCase();
|
||||
final nameNoStopwords = _normalizer.normalizeTextNoStopwords(item.name);
|
||||
|
||||
// Calculate primary score based on name matching
|
||||
double score = _calculatePrimaryScore(nameLower, nameNoStopwords, nq);
|
||||
|
||||
// Add secondary field bonuses based on media type
|
||||
score += _calculateSecondaryScore(item, nq);
|
||||
|
||||
// Add library/favorite bonuses
|
||||
score += _calculateBonuses(item);
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// Calculate primary score based on name matching.
|
||||
double _calculatePrimaryScore(
|
||||
String nameLower,
|
||||
String nameNoStopwords,
|
||||
NormalizedQuery nq,
|
||||
) {
|
||||
// Tier 1: Exact match (highest priority)
|
||||
if (nameLower == nq.normalized) {
|
||||
return _config.exactMatch;
|
||||
}
|
||||
if (nameNoStopwords == nq.withoutStopwords) {
|
||||
return _config.exactMatchNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 2: Starts with
|
||||
if (nameLower.startsWith(nq.normalized)) {
|
||||
return _config.startsWithMatch;
|
||||
}
|
||||
if (nameNoStopwords.startsWith(nq.withoutStopwords)) {
|
||||
return _config.startsWithNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 3: Word boundary match
|
||||
if (_matchesWordBoundary(nameLower, nq.normalized)) {
|
||||
return _config.wordBoundaryMatch;
|
||||
}
|
||||
if (_matchesWordBoundary(nameNoStopwords, nq.withoutStopwords)) {
|
||||
return _config.wordBoundaryNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 4: Reverse contains (result name IN query)
|
||||
// This solves "the ramones" finding "Ramones"
|
||||
if (_reverseContains(nq.normalized, nameLower)) {
|
||||
return _config.reverseContainsMatch;
|
||||
}
|
||||
if (_reverseContains(nq.withoutStopwords, nameNoStopwords)) {
|
||||
return _config.reverseContainsNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 5: Contains anywhere
|
||||
if (nameLower.contains(nq.normalized)) {
|
||||
return _config.containsMatch;
|
||||
}
|
||||
if (nameNoStopwords.contains(nq.withoutStopwords)) {
|
||||
return _config.containsNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 6: Fuzzy matching (typo tolerance)
|
||||
final fuzzyScore = _fuzzyMatcher.jaroWinklerSimilarity(
|
||||
nq.withoutStopwords,
|
||||
nameNoStopwords,
|
||||
);
|
||||
if (fuzzyScore >= _config.fuzzyHighThreshold) {
|
||||
// Scale score based on similarity (0.90-1.0 -> 40-45)
|
||||
return _config.fuzzyMatchHigh +
|
||||
((fuzzyScore - _config.fuzzyHighThreshold) * 50);
|
||||
}
|
||||
if (fuzzyScore >= _config.fuzzyMediumThreshold) {
|
||||
return _config.fuzzyMatchMedium;
|
||||
}
|
||||
|
||||
// Tier 7: Token-level fuzzy (individual words)
|
||||
final tokenFuzzy = _fuzzyMatcher.bestTokenMatch(
|
||||
nq.tokensNoStop,
|
||||
_normalizer.tokenize(nameNoStopwords),
|
||||
);
|
||||
if (tokenFuzzy >= _config.fuzzyHighThreshold) {
|
||||
return _config.fuzzyMatchMedium;
|
||||
}
|
||||
|
||||
// Tier 8: N-gram partial matching
|
||||
final ngramScore = _ngramMatcher.bigramSimilarity(
|
||||
nq.withoutStopwords,
|
||||
nameNoStopwords,
|
||||
);
|
||||
if (ngramScore >= _config.ngramThreshold) {
|
||||
// Scale: 0.5-1.0 -> 25-35
|
||||
return _config.ngramMatch + (ngramScore * 10);
|
||||
}
|
||||
|
||||
// Baseline: Music Assistant API returned it, so some relevance
|
||||
return _config.baseline;
|
||||
}
|
||||
|
||||
/// Check if query matches at word boundary in text.
|
||||
bool _matchesWordBoundary(String text, String query) {
|
||||
if (query.contains(' ')) {
|
||||
// Multi-word query: check if at start or after space
|
||||
if (text.startsWith(query)) return true;
|
||||
if (text.contains(' $query')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Single-word query: check if any word starts with query
|
||||
final words = text.split(RegExp(r'\s+'));
|
||||
for (final word in words) {
|
||||
if (word.startsWith(query)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if text is contained within query (reverse matching).
|
||||
///
|
||||
/// Solves: "the ramones" query finding "Ramones" artist
|
||||
bool _reverseContains(String query, String text) {
|
||||
if (text.length < _config.minReverseMatchLength) return false;
|
||||
|
||||
// Direct containment
|
||||
if (query.contains(text)) return true;
|
||||
|
||||
// Token-level: any query token equals text
|
||||
final queryTokens = query.split(RegExp(r'\s+'));
|
||||
for (final token in queryTokens) {
|
||||
if (token.length >= _config.minReverseMatchLength && text == token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Calculate secondary field bonuses based on media type.
|
||||
double _calculateSecondaryScore(MediaItem item, NormalizedQuery nq) {
|
||||
double bonus = 0;
|
||||
|
||||
if (item is Album) {
|
||||
bonus += _scoreArtistField(item.artistsString, nq);
|
||||
} else if (item is Track) {
|
||||
bonus += _scoreArtistField(item.artistsString, nq);
|
||||
// Also check album name
|
||||
if (item.album?.name != null) {
|
||||
final albumLower = item.album!.name.toLowerCase();
|
||||
if (albumLower.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.albumFieldBonus;
|
||||
}
|
||||
}
|
||||
} else if (item is Audiobook) {
|
||||
// Check authors
|
||||
final authorLower = item.authorsString.toLowerCase();
|
||||
if (authorLower == nq.withoutStopwords) {
|
||||
bonus += _config.authorFieldExactBonus;
|
||||
} else if (authorLower.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.authorFieldPartialBonus;
|
||||
}
|
||||
// Check narrators
|
||||
final narratorLower = item.narratorsString.toLowerCase();
|
||||
if (narratorLower.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.narratorFieldBonus;
|
||||
}
|
||||
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
|
||||
bonus += _scorePodcastFields(item, nq);
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// Score artist field for albums and tracks.
|
||||
double _scoreArtistField(String artistsString, NormalizedQuery nq) {
|
||||
final artistLower = artistsString.toLowerCase();
|
||||
if (artistLower == nq.withoutStopwords) {
|
||||
return _config.artistFieldExactBonus;
|
||||
}
|
||||
if (artistLower.contains(nq.withoutStopwords)) {
|
||||
return _config.artistFieldPartialBonus;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Score podcast-specific fields (creator, description).
|
||||
double _scorePodcastFields(MediaItem item, NormalizedQuery nq) {
|
||||
double bonus = 0;
|
||||
final metadata = item.metadata;
|
||||
|
||||
if (metadata != null) {
|
||||
// Check creator fields
|
||||
final creatorFields = [
|
||||
metadata['author'] as String?,
|
||||
metadata['publisher'] as String?,
|
||||
metadata['owner'] as String?,
|
||||
metadata['creator'] as String?,
|
||||
].where((s) => s != null && s.isNotEmpty).map((s) => s!.toLowerCase());
|
||||
|
||||
bool foundExact = false;
|
||||
bool foundContains = false;
|
||||
for (final field in creatorFields) {
|
||||
if (field == nq.withoutStopwords) {
|
||||
foundExact = true;
|
||||
break;
|
||||
} else if (field.contains(nq.withoutStopwords)) {
|
||||
foundContains = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundExact) {
|
||||
bonus += _config.creatorFieldExactBonus;
|
||||
} else if (foundContains) {
|
||||
bonus += _config.creatorFieldPartialBonus;
|
||||
}
|
||||
|
||||
// Check description
|
||||
final description =
|
||||
(metadata['description'] as String? ?? '').toLowerCase();
|
||||
if (description.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.descriptionBonus;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: podcast name prominence check
|
||||
if (bonus == 0) {
|
||||
final nameLower = item.name.toLowerCase();
|
||||
if (nameLower.contains(nq.withoutStopwords)) {
|
||||
if (nq.withoutStopwords.contains(' ')) {
|
||||
// Multi-word query prominence
|
||||
final prominence = nq.withoutStopwords.length / nameLower.length;
|
||||
if (prominence >= 0.5) {
|
||||
bonus += _config.creatorFieldExactBonus;
|
||||
} else if (prominence >= 0.3) {
|
||||
bonus += _config.creatorFieldPartialBonus + 4;
|
||||
} else {
|
||||
bonus += _config.creatorFieldPartialBonus;
|
||||
}
|
||||
} else {
|
||||
bonus += _config.descriptionBonus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// Calculate library and favorite bonuses.
|
||||
double _calculateBonuses(MediaItem item) {
|
||||
double bonus = 0;
|
||||
|
||||
// Library bonus (Album has inLibrary property)
|
||||
if (item is Album && item.inLibrary) {
|
||||
bonus += _config.libraryBonus;
|
||||
}
|
||||
|
||||
// Favorite bonus
|
||||
if (item.favorite == true) {
|
||||
bonus += _config.favoriteBonus;
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/// Text normalization utilities for search scoring.
|
||||
///
|
||||
/// Handles query normalization, stopword removal, and tokenization.
|
||||
|
||||
/// Represents a normalized search query with various forms for matching.
|
||||
class NormalizedQuery {
|
||||
/// Original query as entered by user
|
||||
final String original;
|
||||
|
||||
/// Lowercased and trimmed query
|
||||
final String normalized;
|
||||
|
||||
/// Query with stopwords removed
|
||||
final String withoutStopwords;
|
||||
|
||||
/// Individual tokens from normalized query
|
||||
final List<String> tokens;
|
||||
|
||||
/// Tokens with stopwords removed
|
||||
final List<String> tokensNoStop;
|
||||
|
||||
const NormalizedQuery({
|
||||
required this.original,
|
||||
required this.normalized,
|
||||
required this.withoutStopwords,
|
||||
required this.tokens,
|
||||
required this.tokensNoStop,
|
||||
});
|
||||
|
||||
/// Whether the query is empty after normalization
|
||||
bool get isEmpty => normalized.isEmpty;
|
||||
|
||||
/// Whether stopword removal changed the query
|
||||
bool get hasStopwordsRemoved => normalized != withoutStopwords;
|
||||
}
|
||||
|
||||
/// Normalizes text and queries for search matching.
|
||||
class TextNormalizer {
|
||||
/// Common English stopwords relevant to music/media searches.
|
||||
///
|
||||
/// Intentionally minimal to avoid over-filtering:
|
||||
/// - Articles that commonly prefix artist/album names
|
||||
/// - Common prepositions that add noise
|
||||
static const Set<String> _stopwords = {
|
||||
// Articles
|
||||
'the',
|
||||
'a',
|
||||
'an',
|
||||
// Common prepositions in music/media context
|
||||
'of',
|
||||
'and',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'with',
|
||||
'by',
|
||||
// Common noise words
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'be',
|
||||
};
|
||||
|
||||
/// Normalize a search query into multiple forms for matching.
|
||||
///
|
||||
/// Returns a [NormalizedQuery] containing:
|
||||
/// - Original query
|
||||
/// - Lowercased/trimmed version
|
||||
/// - Version with stopwords removed
|
||||
/// - Tokenized versions
|
||||
NormalizedQuery normalizeQuery(String query) {
|
||||
final normalized = query.toLowerCase().trim();
|
||||
final tokens = tokenize(normalized);
|
||||
final tokensNoStop = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||
|
||||
// If all tokens were stopwords, preserve original tokens
|
||||
// e.g., "The The" (band name) shouldn't become empty
|
||||
final effectiveTokensNoStop =
|
||||
tokensNoStop.isEmpty ? tokens : tokensNoStop;
|
||||
final withoutStopwords = effectiveTokensNoStop.join(' ');
|
||||
|
||||
return NormalizedQuery(
|
||||
original: query,
|
||||
normalized: normalized,
|
||||
withoutStopwords: withoutStopwords,
|
||||
tokens: tokens,
|
||||
tokensNoStop: effectiveTokensNoStop,
|
||||
);
|
||||
}
|
||||
|
||||
/// Normalize text for comparison (lowercase, trim).
|
||||
String normalizeText(String text) {
|
||||
return text.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/// Normalize text with stopwords removed.
|
||||
String normalizeTextNoStopwords(String text) {
|
||||
final tokens = tokenize(text.toLowerCase().trim());
|
||||
final filtered = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||
return filtered.isEmpty ? text.toLowerCase().trim() : filtered.join(' ');
|
||||
}
|
||||
|
||||
/// Split text into tokens on whitespace.
|
||||
List<String> tokenize(String text) {
|
||||
return text.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Check if a word is a stopword.
|
||||
bool isStopword(String word) {
|
||||
return _stopwords.contains(word.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -13,12 +14,16 @@ class AlbumCard extends StatefulWidget {
|
||||
final Album album;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
/// Image decode size in pixels. Defaults to 256.
|
||||
/// Use smaller values (e.g., 128) for list views, larger for grids.
|
||||
final int? imageCacheSize;
|
||||
|
||||
const AlbumCard({
|
||||
super.key,
|
||||
required this.album,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -30,6 +35,10 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
bool _triedFallback = false;
|
||||
bool _maImageFailed = false;
|
||||
String? _cachedMaImageUrl;
|
||||
Timer? _fallbackTimer;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -37,6 +46,12 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
_initFallbackImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fallbackTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initFallbackImage() {
|
||||
// Check if MA has an image after first build, then fetch fallback if needed
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -47,6 +62,16 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
|
||||
if (maImageUrl == null && !_triedFallback) {
|
||||
_triedFallback = true;
|
||||
_scheduleFallbackFetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Schedule fallback fetch with delay to avoid requests during fast scroll
|
||||
void _scheduleFallbackFetch() {
|
||||
_fallbackTimer?.cancel();
|
||||
_fallbackTimer = Timer(_fallbackDelay, () {
|
||||
if (mounted) {
|
||||
_fetchFallbackImage();
|
||||
}
|
||||
});
|
||||
@@ -69,7 +94,7 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
if (!_triedFallback && !_maImageFailed) {
|
||||
_maImageFailed = true;
|
||||
_triedFallback = true;
|
||||
_fetchFallbackImage();
|
||||
_scheduleFallbackFetch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +111,9 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
// Use fallback if MA image failed or wasn't available
|
||||
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
|
||||
|
||||
// PERF: Use appropriate cache size based on display size
|
||||
final cacheSize = widget.imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap ?? () {
|
||||
@@ -118,8 +146,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
|
||||
@@ -38,6 +38,12 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedAlbums?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_albums = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadAlbums();
|
||||
}
|
||||
|
||||
@@ -45,18 +51,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// 1. Try to get cached data synchronously for instant display
|
||||
final cachedAlbums = widget.getCachedAlbums?.call();
|
||||
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_albums = cachedAlbums;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||
try {
|
||||
final freshAlbums = await widget.loadAlbums();
|
||||
if (mounted && freshAlbums.isNotEmpty) {
|
||||
@@ -126,6 +121,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
clipBehavior: Clip.none,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _albums.length,
|
||||
@@ -135,13 +131,16 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||
itemBuilder: (context, index) {
|
||||
final album = _albums[index];
|
||||
return Container(
|
||||
key: ValueKey(album.uri ?? album.itemId),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: AlbumCard(
|
||||
album: album,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Container(
|
||||
key: ValueKey(album.uri ?? album.itemId),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: AlbumCard(
|
||||
album: album,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -32,6 +33,10 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
||||
String? _fallbackImageUrl;
|
||||
bool _triedFallback = false;
|
||||
bool _maImageFailed = false;
|
||||
Timer? _fallbackTimer;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,6 +44,12 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
||||
_loadImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fallbackTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadImage() async {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
|
||||
@@ -56,14 +67,23 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to external sources (MA returned null)
|
||||
_fetchFallbackImage();
|
||||
// Fallback to external sources (MA returned null) - with delay
|
||||
_scheduleFallbackFetch();
|
||||
}
|
||||
|
||||
/// Schedule fallback fetch with delay to avoid requests during fast scroll
|
||||
void _scheduleFallbackFetch() {
|
||||
if (_triedFallback) return;
|
||||
_triedFallback = true;
|
||||
_fallbackTimer?.cancel();
|
||||
_fallbackTimer = Timer(_fallbackDelay, () {
|
||||
if (mounted) {
|
||||
_fetchFallbackImage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _fetchFallbackImage() async {
|
||||
if (_triedFallback) return;
|
||||
_triedFallback = true;
|
||||
|
||||
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
||||
_fallbackImageUrl = fallbackUrl;
|
||||
|
||||
@@ -79,7 +99,7 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
||||
// When MA image fails to load, try Deezer fallback
|
||||
if (!_maImageFailed) {
|
||||
_maImageFailed = true;
|
||||
_fetchFallbackImage();
|
||||
_scheduleFallbackFetch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +120,9 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
||||
width: widget.radius * 2,
|
||||
height: widget.radius * 2,
|
||||
fit: BoxFit.cover,
|
||||
// PERF: Use imageSize for memory cache to reduce decode overhead
|
||||
memCacheWidth: widget.imageSize,
|
||||
memCacheHeight: widget.imageSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => Container(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -14,14 +15,16 @@ class ArtistCard extends StatefulWidget {
|
||||
final Artist artist;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
final double? imageSize;
|
||||
/// Image decode size in pixels. Defaults to 256.
|
||||
/// Use smaller values (e.g., 128) for list views, larger for grids.
|
||||
final int? imageCacheSize;
|
||||
|
||||
const ArtistCard({
|
||||
super.key,
|
||||
required this.artist,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
this.imageSize,
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -34,6 +37,10 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
bool _triedFallback = false;
|
||||
bool _maImageFailed = false;
|
||||
String? _cachedMaImageUrl;
|
||||
Timer? _fallbackTimer;
|
||||
|
||||
/// Delay before fetching fallback images to avoid requests during fast scroll
|
||||
static const _fallbackDelay = Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -42,6 +49,12 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
_initFallbackImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fallbackTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initFallbackImage() {
|
||||
// We'll check if MA has an image after first build, then fetch fallback if needed
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -52,7 +65,17 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
|
||||
if (maImageUrl == null && !_triedFallback) {
|
||||
_triedFallback = true;
|
||||
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
|
||||
_logger.debug('No MA image for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
|
||||
_scheduleFallbackFetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Schedule fallback fetch with delay to avoid requests during fast scroll
|
||||
void _scheduleFallbackFetch() {
|
||||
_fallbackTimer?.cancel();
|
||||
_fallbackTimer = Timer(_fallbackDelay, () {
|
||||
if (mounted) {
|
||||
_fetchFallbackImage();
|
||||
}
|
||||
});
|
||||
@@ -63,8 +86,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
if (!_triedFallback && !_maImageFailed) {
|
||||
_maImageFailed = true;
|
||||
_triedFallback = true;
|
||||
_logger.debug('MA image failed for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
|
||||
_fetchFallbackImage();
|
||||
_logger.debug('MA image failed for "${widget.artist.name}", scheduling fallback', context: 'ArtistCard');
|
||||
_scheduleFallbackFetch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +104,9 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
// Use fallback if MA image failed or wasn't available
|
||||
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
|
||||
|
||||
// PERF: Use appropriate cache size based on display size
|
||||
final cacheSize = widget.imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap ?? () {
|
||||
@@ -115,8 +141,8 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 256,
|
||||
memCacheHeight: 256,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
@@ -163,6 +189,7 @@ class _ArtistCardState extends State<ArtistCard> {
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -38,6 +38,12 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedArtists?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_artists = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadArtists();
|
||||
}
|
||||
|
||||
@@ -45,18 +51,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// 1. Try to get cached data synchronously for instant display
|
||||
final cachedArtists = widget.getCachedArtists?.call();
|
||||
if (cachedArtists != null && cachedArtists.isNotEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_artists = cachedArtists;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||
// Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||
try {
|
||||
final freshArtists = await widget.loadArtists();
|
||||
if (mounted && freshArtists.isNotEmpty) {
|
||||
|
||||
@@ -14,6 +14,8 @@ class AudiobookRow extends StatefulWidget {
|
||||
final Future<List<Audiobook>> Function() loadAudiobooks;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<Audiobook>? Function()? getCachedAudiobooks;
|
||||
|
||||
const AudiobookRow({
|
||||
super.key,
|
||||
@@ -21,6 +23,7 @@ class AudiobookRow extends StatefulWidget {
|
||||
required this.loadAudiobooks,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedAudiobooks,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -28,9 +31,10 @@ class AudiobookRow extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<Audiobook>> _audiobooksFuture;
|
||||
List<Audiobook>? _cachedAudiobooks;
|
||||
List<Audiobook> _audiobooks = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
bool _hasPrecachedAudiobooks = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
@@ -38,25 +42,116 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAudiobooksOnce();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedAudiobooks?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_audiobooks = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadAudiobooks();
|
||||
}
|
||||
|
||||
void _loadAudiobooksOnce() {
|
||||
if (!_hasLoaded) {
|
||||
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
|
||||
_cachedAudiobooks = audiobooks;
|
||||
return audiobooks;
|
||||
});
|
||||
_hasLoaded = true;
|
||||
Future<void> _loadAudiobooks() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data (always update - fresh data may have updated progress)
|
||||
try {
|
||||
final freshAudiobooks = await widget.loadAudiobooks();
|
||||
if (mounted && freshAudiobooks.isNotEmpty) {
|
||||
setState(() {
|
||||
_audiobooks = freshAudiobooks;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precacheAudiobookImages(freshAudiobooks);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-cache audiobook images so Hero animations are smooth on first tap
|
||||
void _precacheAudiobookImages(List<Audiobook> audiobooks) {
|
||||
if (!mounted || _hasPrecachedAudiobooks) return;
|
||||
_hasPrecachedAudiobooks = true;
|
||||
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Only precache first ~10 visible items
|
||||
final audiobooksToCache = audiobooks.take(10);
|
||||
|
||||
for (final audiobook in audiobooksToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(audiobook, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) {
|
||||
// Silently ignore precache errors
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme, MusicAssistantProvider maProvider) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_audiobooks.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_audiobooks.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No audiobooks found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const textAreaHeight = 44.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _audiobooks.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
addRepaintBoundaries: false,
|
||||
itemBuilder: (context, index) {
|
||||
final audiobook = _audiobooks[index];
|
||||
return Container(
|
||||
key: ValueKey(audiobook.uri ?? audiobook.itemId),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: _AudiobookCard(
|
||||
audiobook: audiobook,
|
||||
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
|
||||
maProvider: maProvider,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('AudiobookRow:${widget.title}');
|
||||
super.build(context);
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
@@ -82,62 +177,7 @@ class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClie
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Audiobook>>(
|
||||
future: _audiobooksFuture,
|
||||
builder: (context, snapshot) {
|
||||
final audiobooks = snapshot.data ?? _cachedAudiobooks;
|
||||
|
||||
if (audiobooks == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError && audiobooks == null) {
|
||||
return Center(
|
||||
child: Text('Error: ${snapshot.error}'),
|
||||
);
|
||||
}
|
||||
|
||||
if (audiobooks == null || audiobooks.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No audiobooks found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const textAreaHeight = 44.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: audiobooks.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
addRepaintBoundaries: false,
|
||||
itemBuilder: (context, index) {
|
||||
final audiobook = audiobooks[index];
|
||||
return Container(
|
||||
key: ValueKey(audiobook.uri ?? audiobook.itemId),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: _AudiobookCard(
|
||||
audiobook: audiobook,
|
||||
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
|
||||
maProvider: maProvider,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildContent(contentHeight, colorScheme, maProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,7 +8,9 @@ import '../providers/music_assistant_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
|
||||
import 'expandable_player.dart';
|
||||
import 'player/mini_player_content.dart' show MiniPlayerLayout;
|
||||
import 'player/player_reveal_overlay.dart';
|
||||
|
||||
/// Cached color with contrast adjustment
|
||||
@@ -56,23 +58,24 @@ class BottomSpacing {
|
||||
/// Height of the bottom navigation bar
|
||||
static const double navBarHeight = 56.0;
|
||||
|
||||
/// Height of mini player when visible (64px height + 12px margin)
|
||||
static const double miniPlayerHeight = 76.0;
|
||||
/// Height of mini player when visible (height + 12px margin)
|
||||
static double get miniPlayerHeight => MiniPlayerLayout.height + 12.0;
|
||||
|
||||
/// Space needed when only nav bar is visible (with some extra padding)
|
||||
static const double navBarOnly = navBarHeight + 16.0;
|
||||
|
||||
/// 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 colors
|
||||
class PlayerExpansionState {
|
||||
final double progress;
|
||||
final Color? backgroundColor;
|
||||
PlayerExpansionState(this.progress, this.backgroundColor);
|
||||
final Color? primaryColor;
|
||||
PlayerExpansionState(this.progress, this.backgroundColor, this.primaryColor);
|
||||
}
|
||||
final playerExpansionNotifier = ValueNotifier<PlayerExpansionState>(PlayerExpansionState(0.0, null));
|
||||
final playerExpansionNotifier = ValueNotifier<PlayerExpansionState>(PlayerExpansionState(0.0, null, null));
|
||||
|
||||
/// Wrapper widget that provides a global player overlay above all navigation.
|
||||
///
|
||||
@@ -98,6 +101,21 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
static bool get isPlayerExpanded =>
|
||||
globalPlayerKey.currentState?.isExpanded ?? false;
|
||||
|
||||
/// Check if the queue panel is currently open (animation value > 0.5)
|
||||
static bool get isQueuePanelOpen =>
|
||||
globalPlayerKey.currentState?.isQueuePanelOpen ?? false;
|
||||
|
||||
/// Check if the queue panel is intended to be open (target state)
|
||||
/// Use this for back gesture handling to avoid timing issues during animations
|
||||
static bool get isQueuePanelTargetOpen =>
|
||||
globalPlayerKey.currentState?.isQueuePanelTargetOpen ?? false;
|
||||
|
||||
/// Close the queue panel if open
|
||||
/// [withHaptic]: Set to false for Android back gesture (system provides haptic)
|
||||
static void closeQueuePanel({bool withHaptic = true}) {
|
||||
globalPlayerKey.currentState?.closeQueuePanel(withHaptic: withHaptic);
|
||||
}
|
||||
|
||||
/// Get the current expansion progress (0.0 to 1.0)
|
||||
static double get expansionProgress =>
|
||||
globalPlayerKey.currentState?.expansionProgress ?? 0.0;
|
||||
@@ -472,74 +490,44 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
builder: (context, _) {
|
||||
return Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
// Use adaptive primary color for bottom nav when adaptive theme is enabled
|
||||
final sourceColor = themeProvider.adaptiveTheme
|
||||
? themeProvider.adaptivePrimaryColor
|
||||
: colorScheme.primary;
|
||||
|
||||
// Use cached color computation to avoid expensive HSL operations during scroll
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||
|
||||
// Base background: use adaptive surface color if available, otherwise default surface
|
||||
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
|
||||
? themeProvider.adaptiveSurfaceColor!
|
||||
: colorScheme.surface;
|
||||
|
||||
return ValueListenableBuilder<PlayerExpansionState>(
|
||||
valueListenable: playerExpansionNotifier,
|
||||
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
activeIcon: const Icon(Icons.home_rounded),
|
||||
label: S.of(context)!.home,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.library_music_outlined),
|
||||
activeIcon: const Icon(Icons.library_music_rounded),
|
||||
label: S.of(context)!.library,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
activeIcon: const Icon(Icons.search_rounded),
|
||||
label: S.of(context)!.search,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
activeIcon: const Icon(Icons.settings_rounded),
|
||||
label: S.of(context)!.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (context, expansionState, navBar) {
|
||||
// Only compute colors during animation - this is the hot path
|
||||
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
|
||||
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
|
||||
: baseBgColor;
|
||||
builder: (context, expansionState, _) {
|
||||
// Nav bar color logic - only use adaptive colors when:
|
||||
// 1. Player is expanding/expanded, OR
|
||||
// 2. On a detail screen (isOnDetailScreen)
|
||||
// On home screen with collapsed player: always use default theme colors
|
||||
final bool useAdaptiveColors = themeProvider.adaptiveTheme &&
|
||||
(expansionState.progress > 0 || themeProvider.isOnDetailScreen);
|
||||
|
||||
// Nav bar background color
|
||||
final Color navBgColor;
|
||||
if (expansionState.progress > 0 && expansionState.backgroundColor != null) {
|
||||
// Player is expanding - blend from surface to player's adaptive color
|
||||
navBgColor = Color.lerp(colorScheme.surface, expansionState.backgroundColor, expansionState.progress)!;
|
||||
} else if (useAdaptiveColors) {
|
||||
// On a detail screen - use adaptive surface color for nav bar
|
||||
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
|
||||
navBgColor = adaptiveBg ?? colorScheme.surface;
|
||||
} else {
|
||||
// Home screen with collapsed player - use default surface color
|
||||
navBgColor = colorScheme.surface;
|
||||
}
|
||||
|
||||
// Icon color - only use adaptive colors when appropriate
|
||||
final Color baseSourceColor = (useAdaptiveColors && themeProvider.adaptiveColors != null)
|
||||
? themeProvider.adaptiveColors!.primary
|
||||
: colorScheme.primary;
|
||||
|
||||
// Blend icon color with player's primary color during expansion
|
||||
Color sourceColor = baseSourceColor;
|
||||
if (themeProvider.adaptiveTheme && expansionState.progress > 0 && expansionState.primaryColor != null) {
|
||||
sourceColor = Color.lerp(baseSourceColor, expansionState.primaryColor!, expansionState.progress)!;
|
||||
}
|
||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||
|
||||
// Use AnimatedContainer for smoother transitions instead of rebuilding
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: navBgColor,
|
||||
@@ -553,7 +541,50 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: navBar, // Reuse pre-built navigation bar
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: navigationProvider.selectedIndex,
|
||||
onTap: (index) {
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
if (index == 3) {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
} else if (navigationProvider.selectedIndex == 3) {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
}
|
||||
navigationProvider.setSelectedIndex(index);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedItemColor: navSelectedColor,
|
||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||
elevation: 0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedFontSize: 12,
|
||||
unselectedFontSize: 12,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
activeIcon: const Icon(Icons.home_rounded),
|
||||
label: S.of(context)!.home,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.library_music_outlined),
|
||||
activeIcon: const Icon(Icons.library_music_rounded),
|
||||
label: S.of(context)!.library,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
activeIcon: const Icon(Icons.search_rounded),
|
||||
label: S.of(context)!.search,
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
activeIcon: const Icon(Icons.settings_rounded),
|
||||
label: S.of(context)!.settings,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -642,7 +673,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
left: 24,
|
||||
right: 24,
|
||||
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
|
||||
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32,
|
||||
// Use viewPadding to match BottomNavigationBar's height calculation
|
||||
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).viewPadding.bottom + 32,
|
||||
child: FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: _welcomeFadeController,
|
||||
@@ -683,7 +715,8 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
PlayerRevealOverlay(
|
||||
key: _revealKey,
|
||||
onDismiss: _hidePlayerReveal,
|
||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
|
||||
// Use viewPadding to match BottomNavigationBar's height calculation
|
||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).viewPadding.bottom + 12,
|
||||
miniPlayerHeight: 64,
|
||||
showOnboardingHints: _isOnboardingReveal,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A scrollbar that shows a letter popup when dragging, for fast navigation
|
||||
/// through alphabetically sorted lists.
|
||||
class LetterScrollbar extends StatefulWidget {
|
||||
/// The scrollable child widget (ListView, GridView, etc.)
|
||||
final Widget child;
|
||||
|
||||
/// The scroll controller for the child
|
||||
final ScrollController controller;
|
||||
|
||||
/// List of items to extract letters from (must be sorted alphabetically)
|
||||
/// Each item's first character is used to determine the letter
|
||||
final List<String> items;
|
||||
|
||||
/// Callback when user taps/drags to a specific index
|
||||
final void Function(int index)? onScrollToIndex;
|
||||
|
||||
/// Callback when drag state changes (for disabling scroll-to-hide)
|
||||
final void Function(bool isDragging)? onDragStateChanged;
|
||||
|
||||
const LetterScrollbar({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.controller,
|
||||
required this.items,
|
||||
this.onScrollToIndex,
|
||||
this.onDragStateChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LetterScrollbar> createState() => _LetterScrollbarState();
|
||||
}
|
||||
|
||||
class _LetterScrollbarState extends State<LetterScrollbar> {
|
||||
bool _isDragging = false;
|
||||
String _currentLetter = '';
|
||||
double _dragPosition = 0;
|
||||
double _scrollFraction = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onScrollChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onScrollChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScrollChanged() {
|
||||
if (!_isDragging && widget.controller.hasClients) {
|
||||
final position = widget.controller.position;
|
||||
if (position.maxScrollExtent > 0) {
|
||||
final newFraction = (position.pixels / position.maxScrollExtent).clamp(0.0, 1.0);
|
||||
// PERF: Only rebuild if fraction changed by more than 1% to reduce rebuilds
|
||||
if ((newFraction - _scrollFraction).abs() > 0.01) {
|
||||
setState(() {
|
||||
_scrollFraction = newFraction;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of letter -> first index for that letter
|
||||
Map<String, int> get _letterIndexMap {
|
||||
final map = <String, int>{};
|
||||
for (int i = 0; i < widget.items.length; i++) {
|
||||
final item = widget.items[i];
|
||||
if (item.isNotEmpty) {
|
||||
final letter = item[0].toUpperCase();
|
||||
if (!map.containsKey(letter)) {
|
||||
map[letter] = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
String _getLetterAtPosition(double position, double maxHeight) {
|
||||
if (widget.items.isEmpty || maxHeight <= 0) return '';
|
||||
|
||||
// Calculate which item index corresponds to this position
|
||||
final fraction = (position / maxHeight).clamp(0.0, 1.0);
|
||||
final index = (fraction * (widget.items.length - 1)).round();
|
||||
|
||||
if (index >= 0 && index < widget.items.length) {
|
||||
final item = widget.items[index];
|
||||
if (item.isNotEmpty) {
|
||||
return item[0].toUpperCase();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
void _scrollToLetter(String letter) {
|
||||
final letterMap = _letterIndexMap;
|
||||
if (letterMap.containsKey(letter)) {
|
||||
final index = letterMap[letter]!;
|
||||
if (widget.onScrollToIndex != null) {
|
||||
widget.onScrollToIndex!(index);
|
||||
} else {
|
||||
// Estimate scroll position based on index
|
||||
// This is approximate - works better with fixed height items
|
||||
final scrollController = widget.controller;
|
||||
if (scrollController.hasClients) {
|
||||
final maxScroll = scrollController.position.maxScrollExtent;
|
||||
final fraction = index / widget.items.length;
|
||||
final targetScroll = (fraction * maxScroll).clamp(0.0, maxScroll);
|
||||
|
||||
// Update scroll fraction immediately for smooth thumb tracking
|
||||
setState(() {
|
||||
_scrollFraction = fraction;
|
||||
});
|
||||
|
||||
scrollController.jumpTo(targetScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_dragPosition = details.localPosition.dy;
|
||||
});
|
||||
widget.onDragStateChanged?.call(true);
|
||||
_updateLetterAndScroll(details.localPosition.dy);
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
_dragPosition = details.localPosition.dy;
|
||||
});
|
||||
_updateLetterAndScroll(details.localPosition.dy);
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
});
|
||||
widget.onDragStateChanged?.call(false);
|
||||
}
|
||||
|
||||
void _updateLetterAndScroll(double position) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final height = renderBox.size.height;
|
||||
final letter = _getLetterAtPosition(position, height);
|
||||
if (letter.isNotEmpty && letter != _currentLetter) {
|
||||
setState(() {
|
||||
_currentLetter = letter;
|
||||
});
|
||||
_scrollToLetter(letter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// The scrollable content
|
||||
widget.child,
|
||||
|
||||
// The draggable scrollbar area (right edge) - wider grab area
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 32, // Wider grab area
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _handleDragStart,
|
||||
onVerticalDragUpdate: _handleDragUpdate,
|
||||
onVerticalDragEnd: _handleDragEnd,
|
||||
onTapDown: (details) {
|
||||
_handleDragStart(DragStartDetails(
|
||||
localPosition: details.localPosition,
|
||||
globalPosition: details.globalPosition,
|
||||
));
|
||||
},
|
||||
onTapUp: (_) {
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
});
|
||||
widget.onDragStateChanged?.call(false);
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final trackHeight = constraints.maxHeight;
|
||||
final thumbHeight = _isDragging ? 60.0 : 40.0;
|
||||
final availableTrack = trackHeight - thumbHeight;
|
||||
final thumbTop = availableTrack * _scrollFraction;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Thumb that tracks scroll position - use AnimatedPositioned for smooth motion
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 50),
|
||||
curve: Curves.linear,
|
||||
top: thumbTop,
|
||||
right: 4,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
width: _isDragging ? 6 : 4,
|
||||
height: thumbHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: _isDragging
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// The letter popup bubble
|
||||
if (_isDragging && _currentLetter.isNotEmpty)
|
||||
Positioned(
|
||||
right: 48,
|
||||
top: _dragPosition - 28,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_currentLetter,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
final GestureDragStartCallback? onHorizontalDragStart;
|
||||
final GestureDragUpdateCallback? onHorizontalDragUpdate;
|
||||
final GestureDragEndCallback? onHorizontalDragEnd;
|
||||
final VoidCallback? onPowerToggle;
|
||||
|
||||
const DeviceSelectorBar({
|
||||
super.key,
|
||||
@@ -31,6 +32,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
this.onHorizontalDragStart,
|
||||
this.onHorizontalDragUpdate,
|
||||
this.onHorizontalDragEnd,
|
||||
this.onPowerToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -68,6 +70,8 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
width: width,
|
||||
slideOffset: slideOffset,
|
||||
isHint: swipeHint != null,
|
||||
onPowerToggle: onPowerToggle,
|
||||
isPoweredOn: selectedPlayer.powered,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,18 +3,27 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
/// Shared constants for mini player layout
|
||||
class MiniPlayerLayout {
|
||||
static const double height = 64.0;
|
||||
static const double artSize = 64.0;
|
||||
static const double height = 72.0;
|
||||
static const double artSize = 72.0;
|
||||
static const double textLeftOffset = 10.0; // Gap between art and text
|
||||
static const double textLeft = artSize + textLeftOffset; // 74px
|
||||
static const double primaryTop = 13.0;
|
||||
static const double secondaryTop = 33.0;
|
||||
static const double textLeft = artSize + textLeftOffset; // 82px
|
||||
// 3-line layout: track, artist, player
|
||||
static const double primaryTop = 9.0;
|
||||
static const double secondaryTop = 26.0;
|
||||
static const double tertiaryTop = 46.0;
|
||||
// 2-line layout (evenly spaced): player name, hint
|
||||
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
|
||||
static const double primaryTop2Line = 14.0; // 24 - (18/2) = 15, adjusted to 14
|
||||
static const double secondaryTop2Line = 40.0; // raised slightly from 42
|
||||
static const double textRightPadding = 12.0;
|
||||
static const double powerButtonSize = 40.0; // Power button tap area
|
||||
static const double iconSize = 28.0;
|
||||
static const double iconOpacity = 0.4;
|
||||
static const double secondaryTextOpacity = 0.6;
|
||||
static const double primaryFontSize = 16.0;
|
||||
static const double primaryFontSize = 16.0; // 3-line playing mode
|
||||
static const double primaryFontSize2Line = 18.0; // larger for 2-line off mode
|
||||
static const double secondaryFontSize = 14.0;
|
||||
static const double tertiaryFontSize = 14.0;
|
||||
static const FontWeight primaryFontWeight = FontWeight.w500;
|
||||
}
|
||||
|
||||
@@ -30,6 +39,10 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
/// If null, primary text will be vertically centered
|
||||
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
|
||||
final String? imageUrl;
|
||||
|
||||
@@ -57,10 +70,17 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
|
||||
final double progress;
|
||||
|
||||
/// Callback for power button tap (only shown in 2-line mode when provided)
|
||||
final VoidCallback? onPowerToggle;
|
||||
|
||||
/// Whether the player is currently powered on
|
||||
final bool isPoweredOn;
|
||||
|
||||
const MiniPlayerContent({
|
||||
super.key,
|
||||
required this.primaryText,
|
||||
this.secondaryText,
|
||||
this.tertiaryText,
|
||||
this.imageUrl,
|
||||
required this.playerName,
|
||||
required this.backgroundColor,
|
||||
@@ -70,19 +90,35 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
this.isHint = false,
|
||||
this.showProgress = false,
|
||||
this.progress = 0.0,
|
||||
this.onPowerToggle,
|
||||
this.isPoweredOn = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
|
||||
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
|
||||
final is2LineMode = !hasTertiaryLine;
|
||||
final showPowerButton = is2LineMode && onPowerToggle != null;
|
||||
final slidePixels = slideOffset * width;
|
||||
|
||||
// Calculate text width (leaves room for right padding)
|
||||
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
|
||||
// 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 right padding (extra space for power button in 2-line mode)
|
||||
final rightPadding = showPowerButton
|
||||
? MiniPlayerLayout.powerButtonSize + 8.0
|
||||
: MiniPlayerLayout.textRightPadding;
|
||||
|
||||
// Darkened background for icon area (matches DeviceSelectorBar)
|
||||
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
||||
|
||||
// Font size for primary text (larger in 2-line mode)
|
||||
final primaryFontSize = is2LineMode
|
||||
? MiniPlayerLayout.primaryFontSize2Line
|
||||
: MiniPlayerLayout.primaryFontSize;
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: MiniPlayerLayout.height,
|
||||
@@ -99,10 +135,10 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
child: Container(color: backgroundColor),
|
||||
),
|
||||
|
||||
// Art / Icon area
|
||||
// Art / Icon area - centered vertically
|
||||
Positioned(
|
||||
left: slidePixels,
|
||||
top: 0,
|
||||
top: (MiniPlayerLayout.height - MiniPlayerLayout.artSize) / 2,
|
||||
child: SizedBox(
|
||||
width: MiniPlayerLayout.artSize,
|
||||
height: MiniPlayerLayout.artSize,
|
||||
@@ -125,14 +161,14 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
Positioned(
|
||||
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||
top: hasSecondaryLine
|
||||
? MiniPlayerLayout.primaryTop
|
||||
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
|
||||
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||
? primaryTop
|
||||
: (MiniPlayerLayout.height - primaryFontSize) / 2,
|
||||
right: rightPadding - slidePixels,
|
||||
child: Text(
|
||||
primaryText,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: MiniPlayerLayout.primaryFontSize,
|
||||
fontSize: primaryFontSize,
|
||||
fontWeight: MiniPlayerLayout.primaryFontWeight,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -144,8 +180,8 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
if (hasSecondaryLine)
|
||||
Positioned(
|
||||
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||
top: MiniPlayerLayout.secondaryTop,
|
||||
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||
top: secondaryTop,
|
||||
right: rightPadding - slidePixels,
|
||||
child: isHint
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -179,6 +215,44 @@ class MiniPlayerContent extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
// Power button (only for 2-line mode when callback provided)
|
||||
// Matches PlayerCard power button style
|
||||
if (showPowerButton)
|
||||
Positioned(
|
||||
right: 8.0 - slidePixels,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: IconButton(
|
||||
onPressed: onPowerToggle,
|
||||
icon: Icon(
|
||||
Icons.power_settings_new_rounded,
|
||||
color: isPoweredOn ? textColor : textColor.withOpacity(0.5),
|
||||
size: 20,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../services/debug_logger.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
|
||||
final _volumeLogger = DebugLogger();
|
||||
|
||||
/// 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.
|
||||
class PlayerCard extends StatelessWidget {
|
||||
/// Supports horizontal swipe to adjust volume with a two-tone overlay.
|
||||
class PlayerCard extends StatefulWidget {
|
||||
final dynamic player;
|
||||
final dynamic trackInfo;
|
||||
final String? albumArtUrl;
|
||||
@@ -20,10 +27,24 @@ class PlayerCard extends StatelessWidget {
|
||||
final VoidCallback? onPlayPause;
|
||||
final VoidCallback? onSkipNext;
|
||||
final VoidCallback? onPower;
|
||||
final ValueChanged<double>? onVolumeChange;
|
||||
final ValueChanged<bool>? onPrecisionModeChanged;
|
||||
|
||||
// Pastel yellow for grouped players
|
||||
static const Color groupBorderColor = Color(0xFFFFF59D);
|
||||
|
||||
// Precision mode settings
|
||||
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
|
||||
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
|
||||
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
|
||||
|
||||
// PERF Phase 4: Pre-cached BoxShadow to avoid allocation per frame
|
||||
static const BoxShadow cardShadow = BoxShadow(
|
||||
color: Color(0x33000000), // 20% black
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
);
|
||||
|
||||
const PlayerCard({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -39,174 +60,434 @@ class PlayerCard extends StatelessWidget {
|
||||
this.onPlayPause,
|
||||
this.onSkipNext,
|
||||
this.onPower,
|
||||
this.onVolumeChange,
|
||||
this.onPrecisionModeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayerCard> createState() => _PlayerCardState();
|
||||
}
|
||||
|
||||
class _PlayerCardState extends State<PlayerCard> {
|
||||
bool _isDraggingVolume = false;
|
||||
double _dragVolumeLevel = 0.0;
|
||||
double _cardWidth = 0.0;
|
||||
int _lastVolumeUpdateTime = 0;
|
||||
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
|
||||
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
|
||||
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
||||
static const int _precisionThrottleMs = 50; // Faster updates in precision mode
|
||||
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
|
||||
|
||||
// Precision mode state
|
||||
bool _inPrecisionMode = false;
|
||||
Timer? _precisionTimer;
|
||||
Offset? _lastDragPosition;
|
||||
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
|
||||
bool _precisionModeEnabled = true; // From settings
|
||||
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
|
||||
double _precisionStartX = 0.0; // Finger X position when precision mode started
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPrecisionModeSetting();
|
||||
}
|
||||
|
||||
Future<void> _loadPrecisionModeSetting() async {
|
||||
final enabled = await SettingsService.getVolumePrecisionMode();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_precisionModeEnabled = enabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_precisionTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _enterPrecisionMode() {
|
||||
if (_inPrecisionMode) return;
|
||||
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
|
||||
setState(() {
|
||||
_inPrecisionMode = true;
|
||||
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
|
||||
_precisionStartX = _lastLocalX; // Capture finger position at entry
|
||||
});
|
||||
widget.onPrecisionModeChanged?.call(true);
|
||||
_volumeLogger.debug(
|
||||
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
|
||||
context: 'Volume',
|
||||
);
|
||||
}
|
||||
|
||||
void _exitPrecisionMode() {
|
||||
_precisionTimer?.cancel();
|
||||
_precisionTimer = null;
|
||||
if (!_inPrecisionMode) return;
|
||||
setState(() {
|
||||
_inPrecisionMode = false;
|
||||
});
|
||||
widget.onPrecisionModeChanged?.call(false);
|
||||
_volumeLogger.debug(
|
||||
'PRECISION_MODE [${widget.player.name}]: EXITED',
|
||||
context: 'Volume',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Match mini player dimensions
|
||||
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
|
||||
const double cardHeight = Dimensions.miniPlayerHeight;
|
||||
const double artSize = cardHeight;
|
||||
const double borderRadius = Radii.xl; // 16px - same as mini player
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
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(),
|
||||
),
|
||||
// Colors for volume overlay (same as mini player progress bar)
|
||||
final filledColor = widget.backgroundColor;
|
||||
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
|
||||
|
||||
// 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(),
|
||||
// RepaintBoundary isolates this card's repaint from parent transform animations
|
||||
// This improves performance during the staggered reveal animation
|
||||
return RepaintBoundary(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_cardWidth = constraints.maxWidth;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _isDraggingVolume ? null : widget.onTap,
|
||||
onLongPress: _isDraggingVolume ? null : widget.onLongPress,
|
||||
onHorizontalDragStart: _onDragStart,
|
||||
onHorizontalDragUpdate: _onDragUpdate,
|
||||
onHorizontalDragEnd: _onDragEnd,
|
||||
onHorizontalDragCancel: _onDragCancel,
|
||||
// Use Stack to render border ON TOP of content, preventing album art clipping
|
||||
child: Stack(
|
||||
children: [
|
||||
// Main card content (hidden when dragging volume)
|
||||
if (!_isDraggingVolume)
|
||||
_buildCardContent(cardHeight, artSize, borderRadius),
|
||||
|
||||
// Volume overlay (shown when dragging)
|
||||
if (_isDraggingVolume)
|
||||
Container(
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: unfilledColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
// PERF Phase 4: Use pre-cached static BoxShadow
|
||||
boxShadow: const [PlayerCard.cardShadow],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
|
||||
heightFactor: 1.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: filledColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(borderRadius),
|
||||
bottomLeft: Radius.circular(borderRadius),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
player.name,
|
||||
style: TextStyle(
|
||||
color: 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: textColor.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
fontFamily: 'Roboto',
|
||||
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,
|
||||
// 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),
|
||||
),
|
||||
onPressed: onPlayPause,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Skip next - nudged right to close gap with power
|
||||
Transform.translate(
|
||||
offset: const Offset(6, 0),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.skip_next_rounded,
|
||||
color: textColor,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: onSkipNext,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Power button - smallest
|
||||
if (player.available)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.power_settings_new_rounded,
|
||||
color: player.powered ? textColor : textColor.withOpacity(0.5),
|
||||
size: 20,
|
||||
),
|
||||
onPressed: onPower,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardContent(double cardHeight, double artSize, double borderRadius) {
|
||||
return Container(
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
// PERF Phase 4: Use pre-cached static BoxShadow
|
||||
boxShadow: const [PlayerCard.cardShadow],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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(
|
||||
icon: Icon(
|
||||
widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||
color: widget.textColor,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: widget.onPlayPause,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Skip next - nudged right to close gap with power
|
||||
Transform.translate(
|
||||
offset: const Offset(6, 0),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.skip_next_rounded,
|
||||
color: widget.textColor,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: widget.onSkipNext,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 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) {
|
||||
// For consecutive swipes, use local volume (API may not have updated player state yet)
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final timeSinceLastDrag = now - _lastDragEndTime;
|
||||
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
|
||||
|
||||
// Use local volume if:
|
||||
// 1. We have a local override (we've swiped before) AND
|
||||
// 2. We're within the consecutive swipe window (5 seconds)
|
||||
// Otherwise, read fresh from player state
|
||||
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
|
||||
|
||||
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
|
||||
final startVolume = useLocalVolume
|
||||
? _dragVolumeLevel // Continue from where last swipe ended
|
||||
: playerVolume; // Fresh from player
|
||||
|
||||
_volumeLogger.debug(
|
||||
'DRAG_START [${widget.player.name}]: '
|
||||
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
|
||||
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
|
||||
'localVol=${(_dragVolumeLevel * 100).round()}%, '
|
||||
'playerVol=${(playerVolume * 100).round()}%, '
|
||||
'startVol=${(startVolume * 100).round()}%',
|
||||
context: 'Volume',
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isDraggingVolume = true;
|
||||
_dragVolumeLevel = startVolume;
|
||||
});
|
||||
_lastDragPosition = details.globalPosition;
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
if (!_isDraggingVolume || _cardWidth <= 0) return;
|
||||
|
||||
// Track local X position for precision mode
|
||||
_lastLocalX = details.localPosition.dx;
|
||||
|
||||
// Check for stillness to trigger precision mode (only if enabled in settings)
|
||||
final currentPosition = details.globalPosition;
|
||||
if (_precisionModeEnabled && _lastDragPosition != null) {
|
||||
final movement = (currentPosition - _lastDragPosition!).distance;
|
||||
|
||||
if (movement < PlayerCard.precisionStillnessThreshold) {
|
||||
// Finger is still - start precision timer if not already running
|
||||
if (_precisionTimer == null && !_inPrecisionMode) {
|
||||
_precisionTimer = Timer(
|
||||
Duration(milliseconds: PlayerCard.precisionTriggerMs),
|
||||
_enterPrecisionMode,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Finger moved - cancel timer (but don't exit precision mode if already in it)
|
||||
_precisionTimer?.cancel();
|
||||
_precisionTimer = null;
|
||||
}
|
||||
}
|
||||
_lastDragPosition = currentPosition;
|
||||
|
||||
double newVolume;
|
||||
|
||||
if (_inPrecisionMode) {
|
||||
// PRECISION MODE: Movement from entry point maps to zoomed range
|
||||
// Full card width of movement = precisionSensitivity (10%) change
|
||||
// e.g., at 40% center: moving full card right = 50%, full card left = 30%
|
||||
final offsetX = details.localPosition.dx - _precisionStartX;
|
||||
final normalizedOffset = offsetX / _cardWidth; // -1.0 to +1.0 range
|
||||
final volumeChange = normalizedOffset * PlayerCard.precisionSensitivity;
|
||||
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
|
||||
} else {
|
||||
// NORMAL MODE: Delta-based movement (full card width = 100%)
|
||||
final dragDelta = details.delta.dx;
|
||||
final volumeDelta = dragDelta / _cardWidth;
|
||||
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 (faster in precision mode)
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
|
||||
if (now - _lastVolumeUpdateTime >= throttleMs) {
|
||||
_lastVolumeUpdateTime = now;
|
||||
widget.onVolumeChange?.call(newVolume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails details) {
|
||||
// Send final volume on release
|
||||
_volumeLogger.debug(
|
||||
'DRAG_END [${widget.player.name}]: '
|
||||
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
|
||||
'precisionMode=$_inPrecisionMode',
|
||||
context: 'Volume',
|
||||
);
|
||||
widget.onVolumeChange?.call(_dragVolumeLevel);
|
||||
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
|
||||
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
|
||||
_exitPrecisionMode();
|
||||
_lastDragPosition = null;
|
||||
setState(() {
|
||||
_isDraggingVolume = false;
|
||||
});
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
void _onDragCancel() {
|
||||
_exitPrecisionMode();
|
||||
_lastDragPosition = null;
|
||||
setState(() {
|
||||
_isDraggingVolume = false;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildSpeakerIcon() {
|
||||
// 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(
|
||||
color: iconBgColor,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.speaker_rounded,
|
||||
color: textColor.withOpacity(0.4),
|
||||
color: widget.textColor.withOpacity(0.4),
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
@@ -214,30 +495,30 @@ class PlayerCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
Color _getStatusColor() {
|
||||
if (!player.available) {
|
||||
if (!widget.player.available) {
|
||||
return Colors.grey.withOpacity(0.5);
|
||||
}
|
||||
if (!player.powered) {
|
||||
if (!widget.player.powered) {
|
||||
return Colors.grey;
|
||||
}
|
||||
if (isPlaying) {
|
||||
if (widget.isPlaying) {
|
||||
return Colors.green;
|
||||
}
|
||||
if (trackInfo != null) {
|
||||
if (widget.trackInfo != null) {
|
||||
return Colors.orange; // Has content but paused
|
||||
}
|
||||
return Colors.grey.shade400; // Idle
|
||||
}
|
||||
|
||||
String _getSubtitle(BuildContext context) {
|
||||
if (!player.available) {
|
||||
if (!widget.player.available) {
|
||||
return S.of(context)!.playerStateUnavailable;
|
||||
}
|
||||
if (!player.powered) {
|
||||
if (!widget.player.powered) {
|
||||
return S.of(context)!.playerStateOff;
|
||||
}
|
||||
if (trackInfo != null) {
|
||||
return trackInfo.name ?? S.of(context)!.playerStateIdle;
|
||||
if (widget.trackInfo != null) {
|
||||
return widget.trackInfo.name ?? S.of(context)!.playerStateIdle;
|
||||
}
|
||||
return S.of(context)!.playerStateIdle;
|
||||
}
|
||||
|
||||
@@ -59,19 +59,25 @@ class PlayerControls extends StatelessWidget {
|
||||
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||
_buildSecondaryButton(
|
||||
icon: Icons.shuffle_rounded,
|
||||
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
||||
.withOpacity(expandedElementsOpacity),
|
||||
// Fix: compute final opacity once to avoid multiplicative opacity bug
|
||||
color: shuffle == true
|
||||
? primaryColor.withOpacity(expandedElementsOpacity)
|
||||
: textColor.withOpacity(0.5 * expandedElementsOpacity),
|
||||
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
||||
),
|
||||
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||
|
||||
// Previous
|
||||
_buildControlButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: textColor,
|
||||
size: skipButtonSize,
|
||||
onPressed: onPrevious,
|
||||
useAnimation: isExpanded,
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Previous track',
|
||||
child: _buildControlButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: textColor,
|
||||
size: skipButtonSize,
|
||||
onPressed: onPrevious,
|
||||
useAnimation: isExpanded,
|
||||
),
|
||||
),
|
||||
SizedBox(width: _lerpDouble(0, 20, t)),
|
||||
|
||||
@@ -80,12 +86,16 @@ class PlayerControls extends StatelessWidget {
|
||||
SizedBox(width: _lerpDouble(0, 20, t)),
|
||||
|
||||
// Next
|
||||
_buildControlButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: textColor,
|
||||
size: skipButtonSize,
|
||||
onPressed: onNext,
|
||||
useAnimation: isExpanded,
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Next track',
|
||||
child: _buildControlButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: textColor,
|
||||
size: skipButtonSize,
|
||||
onPressed: onNext,
|
||||
useAnimation: isExpanded,
|
||||
),
|
||||
),
|
||||
|
||||
// Repeat (expanded only)
|
||||
@@ -94,10 +104,10 @@ class PlayerControls extends StatelessWidget {
|
||||
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||
_buildSecondaryButton(
|
||||
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
||||
color: (repeatMode != null && repeatMode != 'off'
|
||||
? primaryColor
|
||||
: textColor.withOpacity(0.5))
|
||||
.withOpacity(expandedElementsOpacity),
|
||||
// Fix: compute final opacity once to avoid multiplicative opacity bug
|
||||
color: (repeatMode != null && repeatMode != 'off')
|
||||
? primaryColor.withOpacity(expandedElementsOpacity)
|
||||
: textColor.withOpacity(0.5 * expandedElementsOpacity),
|
||||
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
||||
),
|
||||
],
|
||||
@@ -151,21 +161,26 @@ class PlayerControls extends StatelessWidget {
|
||||
final bgColor = Color.lerp(Colors.transparent, primaryColor, progress);
|
||||
final iconColor = Color.lerp(textColor, backgroundColor, progress);
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: onStop,
|
||||
child: Container(
|
||||
width: playButtonContainerSize,
|
||||
height: playButtonContainerSize,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
|
||||
color: iconColor,
|
||||
iconSize: playButtonSize,
|
||||
onPressed: onPlayPause,
|
||||
padding: EdgeInsets.zero,
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: isPlaying ? 'Pause' : 'Play',
|
||||
hint: 'Long press to stop',
|
||||
child: GestureDetector(
|
||||
onLongPress: onStop,
|
||||
child: Container(
|
||||
width: playButtonContainerSize,
|
||||
height: playButtonContainerSize,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
|
||||
color: iconColor,
|
||||
iconSize: playButtonSize,
|
||||
onPressed: onPlayPause,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -54,6 +54,13 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Hint system
|
||||
bool _showHints = true;
|
||||
|
||||
// PERF: Pre-cached static BoxShadow to avoid allocation per frame
|
||||
static const BoxShadow _cardShadow = BoxShadow(
|
||||
color: Color(0x33000000), // 20% black
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -231,6 +238,9 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// PERF Phase 4: Pre-compute hint colors outside animation loop
|
||||
final hintColor = colorScheme.onSurface.withOpacity(0.7);
|
||||
|
||||
// Back gesture is handled at GlobalPlayerOverlay level
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
@@ -267,6 +277,31 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
}
|
||||
});
|
||||
|
||||
// PERF Phase 4: Pre-compute player card data OUTSIDE AnimatedBuilder
|
||||
// This avoids re-gathering player data every animation frame
|
||||
final playerDataList = players.map((player) {
|
||||
final isPlaying = player.state == 'playing';
|
||||
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||
String? albumArtUrl;
|
||||
if (playerTrack != null && player.available && player.powered) {
|
||||
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
||||
}
|
||||
final playerColorScheme = _playerColors[player.playerId];
|
||||
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
||||
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
||||
final isGrouped = maProvider.isPlayerManuallySynced(player.playerId);
|
||||
|
||||
return _PlayerCardData(
|
||||
player: player,
|
||||
playerTrack: playerTrack,
|
||||
albumArtUrl: albumArtUrl,
|
||||
isPlaying: isPlaying,
|
||||
isGrouped: isGrouped,
|
||||
cardBgColor: cardBgColor,
|
||||
cardTextColor: cardTextColor,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _revealAnimation,
|
||||
builder: (context, child) {
|
||||
@@ -307,9 +342,29 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
|
||||
// Show different hint for onboarding vs regular use
|
||||
final isOnboarding = widget.showOnboardingHints;
|
||||
final hintText = isOnboarding
|
||||
? S.of(context)!.selectPlayerHint
|
||||
: S.of(context)!.holdToSync;
|
||||
|
||||
// PERF Phase 4: Use pre-computed hintColor
|
||||
Widget buildHintRow(IconData icon, String text) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: hintColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: hintColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(0, hintSlideOffset),
|
||||
@@ -317,25 +372,16 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isOnboarding ? Icons.touch_app_outlined : Icons.lightbulb_outline,
|
||||
size: 18,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
hintText,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
child: isOnboarding
|
||||
? buildHintRow(Icons.touch_app_outlined, S.of(context)!.selectPlayerHint)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -344,31 +390,23 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Build player cards - scrollable when overflow, otherwise static
|
||||
// Using ListView.builder for many players: only builds visible items
|
||||
// This significantly improves animation performance with 10+ players
|
||||
// PERF Phase 4: Use pre-computed playerDataList
|
||||
if (needsScroll)
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxListHeight),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemCount: players.length,
|
||||
itemCount: playerDataList.length,
|
||||
// Fixed height per item for optimal scroll performance
|
||||
itemExtent: cardHeight + cardSpacing,
|
||||
itemBuilder: (context, index) {
|
||||
final player = players[index];
|
||||
final isPlaying = player.state == 'playing';
|
||||
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||
String? albumArtUrl;
|
||||
if (playerTrack != null && player.available && player.powered) {
|
||||
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
||||
}
|
||||
final playerColorScheme = _playerColors[player.playerId];
|
||||
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
||||
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
||||
final data = playerDataList[index];
|
||||
|
||||
// Animation: slide from behind mini player
|
||||
// Only calculate for items that will be built (visible ones)
|
||||
const baseOffset = 80.0;
|
||||
final reverseIndex = players.length - 1 - index;
|
||||
final reverseIndex = playerDataList.length - 1 - index;
|
||||
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||
final slideOffset = distanceToTravel * (1.0 - t);
|
||||
|
||||
@@ -377,32 +415,33 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: cardSpacing),
|
||||
child: PlayerCard(
|
||||
player: player,
|
||||
trackInfo: playerTrack,
|
||||
albumArtUrl: albumArtUrl,
|
||||
player: data.player,
|
||||
trackInfo: data.playerTrack,
|
||||
albumArtUrl: data.albumArtUrl,
|
||||
isSelected: false,
|
||||
isPlaying: isPlaying,
|
||||
isGrouped: player.isGrouped,
|
||||
backgroundColor: cardBgColor,
|
||||
textColor: cardTextColor,
|
||||
isPlaying: data.isPlaying,
|
||||
isGrouped: data.isGrouped,
|
||||
backgroundColor: data.cardBgColor,
|
||||
textColor: data.cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.selectPlayer(data.player);
|
||||
dismiss();
|
||||
},
|
||||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.togglePlayerSync(player.playerId);
|
||||
maProvider.togglePlayerSync(data.player.playerId);
|
||||
},
|
||||
onPlayPause: () {
|
||||
if (isPlaying) {
|
||||
maProvider.pausePlayer(player.playerId);
|
||||
if (data.isPlaying) {
|
||||
maProvider.pausePlayer(data.player.playerId);
|
||||
} else {
|
||||
maProvider.resumePlayer(player.playerId);
|
||||
maProvider.resumePlayer(data.player.playerId);
|
||||
}
|
||||
},
|
||||
onSkipNext: () => maProvider.nextTrack(player.playerId),
|
||||
onPower: () => maProvider.togglePower(player.playerId),
|
||||
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
|
||||
onPower: () => maProvider.togglePower(data.player.playerId),
|
||||
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -411,28 +450,14 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
)
|
||||
else
|
||||
// Non-scrollable list for when players fit
|
||||
...List.generate(players.length, (index) {
|
||||
final player = players[index];
|
||||
final isPlaying = player.state == 'playing';
|
||||
|
||||
// Get track info for this player
|
||||
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||
|
||||
// Get album art URL - use track directly like mini player does
|
||||
String? albumArtUrl;
|
||||
if (playerTrack != null && player.available && player.powered) {
|
||||
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
||||
}
|
||||
|
||||
// Use per-player colors if available, otherwise use defaults
|
||||
final playerColorScheme = _playerColors[player.playerId];
|
||||
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
||||
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
||||
// PERF Phase 4: Use pre-computed playerDataList
|
||||
...List.generate(playerDataList.length, (index) {
|
||||
final data = playerDataList[index];
|
||||
|
||||
// Animation: all cards start hidden behind mini player
|
||||
// and fan out to their final positions with spring physics
|
||||
const baseOffset = 80.0;
|
||||
final reverseIndex = players.length - 1 - index;
|
||||
final reverseIndex = playerDataList.length - 1 - index;
|
||||
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||
|
||||
// Use the elastic animation value directly (already has bounce)
|
||||
@@ -443,43 +468,33 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: PlayerCard(
|
||||
player: player,
|
||||
trackInfo: playerTrack,
|
||||
albumArtUrl: albumArtUrl,
|
||||
player: data.player,
|
||||
trackInfo: data.playerTrack,
|
||||
albumArtUrl: data.albumArtUrl,
|
||||
isSelected: false,
|
||||
isPlaying: isPlaying,
|
||||
isGrouped: player.isGrouped,
|
||||
backgroundColor: cardBgColor,
|
||||
textColor: cardTextColor,
|
||||
isPlaying: data.isPlaying,
|
||||
isGrouped: data.isGrouped,
|
||||
backgroundColor: data.cardBgColor,
|
||||
textColor: data.cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.selectPlayer(data.player);
|
||||
dismiss();
|
||||
},
|
||||
onLongPress: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Syncing ${player.name}...'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
maProvider.togglePlayerSync(player.playerId);
|
||||
maProvider.togglePlayerSync(data.player.playerId);
|
||||
},
|
||||
onPlayPause: () {
|
||||
if (isPlaying) {
|
||||
maProvider.pausePlayer(player.playerId);
|
||||
if (data.isPlaying) {
|
||||
maProvider.pausePlayer(data.player.playerId);
|
||||
} else {
|
||||
maProvider.resumePlayer(player.playerId);
|
||||
maProvider.resumePlayer(data.player.playerId);
|
||||
}
|
||||
},
|
||||
onSkipNext: () {
|
||||
maProvider.nextTrack(player.playerId);
|
||||
},
|
||||
onPower: () {
|
||||
maProvider.togglePower(player.playerId);
|
||||
},
|
||||
onSkipNext: () => maProvider.nextTrack(data.player.playerId),
|
||||
onPower: () => maProvider.togglePower(data.player.playerId),
|
||||
onVolumeChange: (volume) => maProvider.setVolume(data.player.playerId, (volume * 100).round()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -496,3 +511,24 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PERF Phase 4: Pre-computed player card data to avoid gathering per animation frame
|
||||
class _PlayerCardData {
|
||||
final dynamic player;
|
||||
final dynamic playerTrack;
|
||||
final String? albumArtUrl;
|
||||
final bool isPlaying;
|
||||
final bool isGrouped;
|
||||
final Color cardBgColor;
|
||||
final Color cardTextColor;
|
||||
|
||||
const _PlayerCardData({
|
||||
required this.player,
|
||||
required this.playerTrack,
|
||||
required this.albumArtUrl,
|
||||
required this.isPlaying,
|
||||
required this.isGrouped,
|
||||
required this.cardBgColor,
|
||||
required this.cardTextColor,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../models/player.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../common/empty_state.dart';
|
||||
|
||||
/// Panel that displays the current playback queue
|
||||
class QueuePanel extends StatelessWidget {
|
||||
/// Panel that displays the current playback queue with drag-to-reorder
|
||||
/// and swipe-left-to-delete functionality.
|
||||
///
|
||||
/// Uses custom AnimatedList implementation instead of great_list_view
|
||||
/// to avoid grey screen bugs (ReorderableListView) and drag duplication bugs (great_list_view).
|
||||
class QueuePanel extends StatefulWidget {
|
||||
final MusicAssistantProvider maProvider;
|
||||
final PlayerQueue? queue;
|
||||
final bool isLoading;
|
||||
@@ -16,6 +23,10 @@ class QueuePanel extends StatelessWidget {
|
||||
final double topPadding;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onRefresh;
|
||||
final ValueChanged<bool>? onDraggingChanged;
|
||||
final VoidCallback? onSwipeStart;
|
||||
final ValueChanged<double>? onSwipeUpdate; // dx delta from start
|
||||
final void Function(double velocity, double totalDx)? onSwipeEnd; // velocity and total displacement
|
||||
|
||||
const QueuePanel({
|
||||
super.key,
|
||||
@@ -28,151 +39,713 @@ class QueuePanel extends StatelessWidget {
|
||||
required this.topPadding,
|
||||
required this.onClose,
|
||||
required this.onRefresh,
|
||||
this.onDraggingChanged,
|
||||
this.onSwipeStart,
|
||||
this.onSwipeUpdate,
|
||||
this.onSwipeEnd,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: backgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md),
|
||||
onPressed: onClose,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||
onPressed: onRefresh,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
State<QueuePanel> createState() => _QueuePanelState();
|
||||
}
|
||||
|
||||
// Queue content
|
||||
Expanded(
|
||||
child: isLoading
|
||||
? Center(child: CircularProgressIndicator(color: primaryColor))
|
||||
: queue == null || queue!.items.isEmpty
|
||||
? _buildEmptyState(context)
|
||||
: _buildQueueList(),
|
||||
),
|
||||
],
|
||||
class _QueuePanelState extends State<QueuePanel> {
|
||||
List<QueueItem> _items = [];
|
||||
final GlobalKey _stackKey = GlobalKey();
|
||||
|
||||
// Drag state
|
||||
int? _dragIndex;
|
||||
int? _dragStartIndex;
|
||||
double _dragY = 0; // Y position of dragged item relative to Stack
|
||||
double _dragStartY = 0; // Y position when drag started (global)
|
||||
double _dragOffsetInItem = 0; // Offset of touch point within item
|
||||
QueueItem? _dragItem;
|
||||
double _itemHeight = 64.0;
|
||||
bool _pendingReorder = false; // True while waiting for API confirmation
|
||||
Timer? _pendingReorderTimer;
|
||||
|
||||
// Optimistic update for tap-to-skip
|
||||
int? _optimisticCurrentIndex;
|
||||
|
||||
// Swipe-to-close tracking (raw pointer events to bypass gesture arena)
|
||||
Offset? _swipeStart;
|
||||
Offset? _swipeLast;
|
||||
int? _swipeLastTime; // milliseconds since epoch
|
||||
bool _isSwiping = false;
|
||||
bool _swipeLocked = false; // Lock direction once established
|
||||
static const _swipeMinDistance = 8.0; // Min distance to start tracking (reduced for responsiveness)
|
||||
static const _edgeDeadZone = 48.0; // Dead zone for Android back gesture (increased)
|
||||
|
||||
// Track last touch start position for edge detection in Dismissible
|
||||
// Static so it persists across widget rebuilds
|
||||
static double? _lastPointerDownX;
|
||||
static double? _lastScreenWidth;
|
||||
|
||||
// Velocity tracking with multiple samples for smoother calculation
|
||||
final List<_VelocitySample> _velocitySamples = [];
|
||||
static const _maxVelocitySamples = 5;
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items = List.from(widget.queue?.items ?? []);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(QueuePanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Clear optimistic state when server catches up
|
||||
if (_optimisticCurrentIndex != null) {
|
||||
final serverIndex = widget.queue?.currentIndex;
|
||||
if (serverIndex == _optimisticCurrentIndex) {
|
||||
// Server caught up - clear optimistic state
|
||||
_optimisticCurrentIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't update items while dragging or waiting for reorder confirmation
|
||||
if (_dragIndex != null || _pendingReorder) return;
|
||||
|
||||
final newItems = widget.queue?.items ?? [];
|
||||
|
||||
// Only sync when player changes or item count changes (additions/deletions)
|
||||
// Don't sync on order changes - trust our local order after user reorders
|
||||
// This prevents stale server state from overwriting recent local changes
|
||||
final playerChanged = widget.queue?.playerId != oldWidget.queue?.playerId;
|
||||
final countChanged = newItems.length != _items.length;
|
||||
|
||||
if (playerChanged || countChanged) {
|
||||
setState(() {
|
||||
_items = List.from(newItems);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pendingReorderTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatDuration(Duration? duration) {
|
||||
if (duration == null) return '';
|
||||
final totalSeconds = duration.inSeconds;
|
||||
final minutes = totalSeconds ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _handleDelete(QueueItem item, int index) async {
|
||||
// Remove from local list - Dismissible handles the animation
|
||||
setState(() {
|
||||
_items.removeAt(index);
|
||||
});
|
||||
|
||||
// Call API
|
||||
final playerId = widget.queue?.playerId;
|
||||
if (playerId != null) {
|
||||
widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleClearQueue() async {
|
||||
final playerId = widget.queue?.playerId;
|
||||
if (playerId == null) return;
|
||||
|
||||
// Optimistic update - clear local list
|
||||
setState(() {
|
||||
_items.clear();
|
||||
});
|
||||
|
||||
// Call API
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandClear(playerId);
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error clearing queue: $e');
|
||||
// Refresh to restore if failed
|
||||
widget.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
void _resetSwipeState() {
|
||||
_swipeStart = null;
|
||||
_swipeLast = null;
|
||||
_swipeLastTime = null;
|
||||
_isSwiping = false;
|
||||
_swipeLocked = false;
|
||||
_velocitySamples.clear();
|
||||
// NOTE: Don't reset _lastPointerDownX - it's needed by confirmDismiss
|
||||
// which may be called after pointer up
|
||||
}
|
||||
|
||||
/// Check if x position is in the edge dead zone (Android back gesture area)
|
||||
static bool _isInEdgeZone(double x, double screenWidth) {
|
||||
return x < _edgeDeadZone || x > screenWidth - _edgeDeadZone;
|
||||
}
|
||||
|
||||
/// Check if last touch started in edge zone (for Dismissible to check)
|
||||
static bool get lastTouchWasInEdgeZone {
|
||||
if (_lastPointerDownX == null || _lastScreenWidth == null) return false;
|
||||
return _isInEdgeZone(_lastPointerDownX!, _lastScreenWidth!);
|
||||
}
|
||||
|
||||
/// Build an invisible edge absorber that blocks horizontal drags from triggering Dismissible
|
||||
Widget _buildEdgeAbsorber({required bool left}) {
|
||||
return Positioned(
|
||||
left: left ? 0 : null,
|
||||
right: left ? null : 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: _edgeDeadZone,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (_) {}, // Absorb horizontal drags
|
||||
onHorizontalDragUpdate: (_) {},
|
||||
onHorizontalDragEnd: (_) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return EmptyState.queue(context: context);
|
||||
void _addVelocitySample(Offset position, int timeMs) {
|
||||
_velocitySamples.add(_VelocitySample(position, timeMs));
|
||||
if (_velocitySamples.length > _maxVelocitySamples) {
|
||||
_velocitySamples.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
double _calculateAverageVelocity() {
|
||||
if (_velocitySamples.length < 2) return 0.0;
|
||||
|
||||
// Use weighted average of recent samples (more recent = higher weight)
|
||||
double totalVelocity = 0.0;
|
||||
double totalWeight = 0.0;
|
||||
|
||||
for (int i = 1; i < _velocitySamples.length; i++) {
|
||||
final prev = _velocitySamples[i - 1];
|
||||
final curr = _velocitySamples[i];
|
||||
final dt = curr.timeMs - prev.timeMs;
|
||||
if (dt > 0 && dt < 100) { // Only use samples within 100ms
|
||||
final dx = curr.position.dx - prev.position.dx;
|
||||
final velocity = (dx / dt) * 1000; // px/s
|
||||
final weight = i.toDouble(); // Later samples get higher weight
|
||||
totalVelocity += velocity * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
}
|
||||
|
||||
return totalWeight > 0 ? totalVelocity / totalWeight : 0.0;
|
||||
}
|
||||
|
||||
void _startDrag(int index, BuildContext itemContext, Offset globalPosition) {
|
||||
if (_dragIndex != null) return;
|
||||
|
||||
// Clear any pending swipe state to prevent conflicts
|
||||
_resetSwipeState();
|
||||
|
||||
final RenderBox itemBox = itemContext.findRenderObject() as RenderBox;
|
||||
final Offset itemGlobalPos = itemBox.localToGlobal(Offset.zero);
|
||||
_itemHeight = itemBox.size.height;
|
||||
|
||||
// Get Stack's global position for proper coordinate conversion
|
||||
final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final stackGlobalPos = stackBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
|
||||
// Calculate initial position relative to Stack
|
||||
_dragY = itemGlobalPos.dy - stackGlobalPos.dy;
|
||||
_dragStartY = globalPosition.dy;
|
||||
// Remember where in the item the touch started (for smooth following)
|
||||
_dragOffsetInItem = globalPosition.dy - itemGlobalPos.dy;
|
||||
|
||||
setState(() {
|
||||
_dragIndex = index;
|
||||
_dragStartIndex = index;
|
||||
_dragItem = _items[index];
|
||||
});
|
||||
|
||||
// Haptic feedback on drag start
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
// Notify parent that drag started
|
||||
widget.onDraggingChanged?.call(true);
|
||||
}
|
||||
|
||||
void _updateDragPointer(Offset globalPosition) {
|
||||
if (_dragIndex == null) return;
|
||||
|
||||
// Get Stack's global position
|
||||
final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final stackGlobalPos = stackBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
|
||||
// Calculate overlay position relative to Stack, accounting for touch offset
|
||||
_dragY = globalPosition.dy - stackGlobalPos.dy - _dragOffsetInItem;
|
||||
|
||||
// Calculate which index we're hovering over based on movement from start
|
||||
final totalOffset = globalPosition.dy - _dragStartY;
|
||||
final indexOffset = (totalOffset / _itemHeight).round();
|
||||
final targetIndex = (_dragStartIndex! + indexOffset).clamp(0, _items.length - 1);
|
||||
|
||||
if (targetIndex != _dragIndex) {
|
||||
// Reorder items in the list
|
||||
setState(() {
|
||||
final item = _items.removeAt(_dragIndex!);
|
||||
_items.insert(targetIndex, item);
|
||||
_dragIndex = targetIndex;
|
||||
});
|
||||
// Haptic feedback on each reorder
|
||||
HapticFeedback.selectionClick();
|
||||
} else {
|
||||
// Just update position
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _endDrag() async {
|
||||
if (_dragIndex == null || _dragStartIndex == null) return;
|
||||
|
||||
final newIndex = _dragIndex!;
|
||||
final originalIndex = _dragStartIndex!;
|
||||
final item = _items[newIndex];
|
||||
final positionChanged = originalIndex != newIndex;
|
||||
|
||||
// Cancel any existing timer to prevent stacking
|
||||
_pendingReorderTimer?.cancel();
|
||||
|
||||
setState(() {
|
||||
_dragIndex = null;
|
||||
_dragStartIndex = null;
|
||||
_dragItem = null;
|
||||
// Block didUpdateWidget while waiting for server confirmation
|
||||
if (positionChanged) _pendingReorder = true;
|
||||
});
|
||||
|
||||
// Haptic feedback on drop
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
// Notify parent that drag ended
|
||||
widget.onDraggingChanged?.call(false);
|
||||
|
||||
// Call API if position changed
|
||||
if (positionChanged) {
|
||||
final playerId = widget.queue?.playerId;
|
||||
// API uses relative shift: positive = down, negative = up
|
||||
final posShift = newIndex - originalIndex;
|
||||
debugPrint('QueuePanel: Moving ${item.track.name} from $originalIndex to $newIndex (shift: $posShift, queueItemId: ${item.queueItemId})');
|
||||
if (playerId != null) {
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, posShift);
|
||||
debugPrint('QueuePanel: Move API call completed');
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Move API error: $e');
|
||||
}
|
||||
} else {
|
||||
debugPrint('QueuePanel: playerId is null, cannot move');
|
||||
}
|
||||
// Allow updates again after a delay for server state to propagate
|
||||
_pendingReorderTimer = Timer(const Duration(milliseconds: 2000), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingReorder = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelDrag() {
|
||||
if (_dragStartIndex == null) return;
|
||||
|
||||
// Restore original position
|
||||
if (_dragIndex != null && _dragIndex != _dragStartIndex) {
|
||||
setState(() {
|
||||
final item = _items.removeAt(_dragIndex!);
|
||||
_items.insert(_dragStartIndex!, item);
|
||||
});
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_dragIndex = null;
|
||||
_dragStartIndex = null;
|
||||
_dragItem = null;
|
||||
});
|
||||
|
||||
// Notify parent that drag ended
|
||||
widget.onDraggingChanged?.call(false);
|
||||
}
|
||||
|
||||
void _handleTapToSkip(QueueItem item, int index, bool isCurrentItem) async {
|
||||
if (isCurrentItem) return;
|
||||
|
||||
final playerId = widget.queue?.playerId;
|
||||
if (playerId == null) return;
|
||||
|
||||
// Optimistic update: immediately show tapped track as current
|
||||
setState(() {
|
||||
_optimisticCurrentIndex = index;
|
||||
});
|
||||
|
||||
// Call API
|
||||
try {
|
||||
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
|
||||
} catch (e) {
|
||||
debugPrint('QueuePanel: Error playing index: $e');
|
||||
}
|
||||
|
||||
// Clear optimistic state after delay - server state will take over
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_optimisticCurrentIndex = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use Listener for raw pointer events to bypass gesture arena
|
||||
// This allows swipe-to-close to work even over ListView/Dismissible
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
// Store touch position for edge detection (used by Dismissible confirmDismiss)
|
||||
// Use static variables so they persist across rebuilds
|
||||
_lastPointerDownX = event.position.dx;
|
||||
_lastScreenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
// Check if touch started in edge zone (Android back gesture area)
|
||||
final startedInEdgeZone = _isInEdgeZone(event.position.dx, _lastScreenWidth!);
|
||||
|
||||
// Don't track swipe while dragging a queue item
|
||||
if (_dragIndex == null && !startedInEdgeZone) {
|
||||
_swipeStart = event.position;
|
||||
_swipeLast = event.position;
|
||||
_swipeLastTime = DateTime.now().millisecondsSinceEpoch;
|
||||
_isSwiping = false;
|
||||
_swipeLocked = false;
|
||||
_velocitySamples.clear();
|
||||
_addVelocitySample(event.position, _swipeLastTime!);
|
||||
} else if (startedInEdgeZone) {
|
||||
// Clear swipe state for edge touches
|
||||
_swipeStart = null;
|
||||
}
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
// Ignore swipes from edge zone (let Android back gesture handle it)
|
||||
if (_swipeStart == null || _dragIndex != null) return;
|
||||
|
||||
final dx = event.position.dx - _swipeStart!.dx;
|
||||
final dy = (event.position.dy - _swipeStart!.dy).abs();
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Track all move events for velocity calculation
|
||||
_addVelocitySample(event.position, now);
|
||||
_swipeLast = event.position;
|
||||
_swipeLastTime = now;
|
||||
|
||||
// Once direction is locked, maintain it (prevents accidental cancellation)
|
||||
if (_swipeLocked && _isSwiping) {
|
||||
widget.onSwipeUpdate?.call(dx.clamp(0.0, double.infinity));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a horizontal swipe (reduced tolerance: dx > dy * 1.2)
|
||||
final isHorizontal = dx.abs() > _swipeMinDistance && dx.abs() > dy * 1.2;
|
||||
|
||||
if (isHorizontal && dx > 0) {
|
||||
// Horizontal swipe right - close gesture
|
||||
if (!_isSwiping) {
|
||||
_isSwiping = true;
|
||||
_swipeLocked = true; // Lock direction once swipe starts
|
||||
widget.onSwipeStart?.call();
|
||||
}
|
||||
widget.onSwipeUpdate?.call(dx);
|
||||
} else if (!_swipeLocked && dx.abs() > _swipeMinDistance) {
|
||||
// Direction established as vertical - don't start swipe
|
||||
_swipeLocked = true; // Lock as non-swipe
|
||||
}
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
if (_isSwiping && _swipeStart != null) {
|
||||
final velocity = _calculateAverageVelocity();
|
||||
final totalDx = event.position.dx - _swipeStart!.dx;
|
||||
widget.onSwipeEnd?.call(velocity, totalDx);
|
||||
}
|
||||
_resetSwipeState();
|
||||
},
|
||||
onPointerCancel: (_) {
|
||||
if (_isSwiping) {
|
||||
widget.onSwipeEnd?.call(0, 0); // Cancel - no action
|
||||
}
|
||||
_resetSwipeState();
|
||||
},
|
||||
child: Container(
|
||||
color: widget.backgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
|
||||
onPressed: widget.onClose,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
color: widget.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Clear queue button
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_sweep_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||
onPressed: _handleClearQueue,
|
||||
padding: Spacing.paddingAll12,
|
||||
tooltip: 'Clear queue',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Queue content
|
||||
Expanded(
|
||||
child: widget.isLoading
|
||||
? Center(child: CircularProgressIndicator(color: widget.primaryColor))
|
||||
: widget.queue == null || _items.isEmpty
|
||||
? EmptyState.queue(context: context)
|
||||
: Listener(
|
||||
// Capture pointer events anywhere while dragging
|
||||
onPointerMove: (event) {
|
||||
if (_dragIndex != null) {
|
||||
_updateDragPointer(event.position);
|
||||
}
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
if (_dragIndex != null) {
|
||||
_endDrag();
|
||||
}
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
if (_dragIndex != null) {
|
||||
_cancelDrag();
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
key: _stackKey,
|
||||
children: [
|
||||
_buildQueueList(),
|
||||
// Dragged item overlay
|
||||
if (_dragIndex != null && _dragItem != null)
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: _dragY,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildQueueItemContent(_dragItem!, _dragIndex!, false, false),
|
||||
),
|
||||
),
|
||||
// Edge gesture absorbers - block horizontal drags from screen edges
|
||||
// This prevents Android back gesture from triggering Dismissible
|
||||
_buildEdgeAbsorber(left: true),
|
||||
_buildEdgeAbsorber(left: false),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueList() {
|
||||
final currentIndex = queue!.currentIndex ?? 0;
|
||||
final items = queue!.items;
|
||||
// Use optimistic index if set, otherwise use server state
|
||||
final currentIndex = _optimisticCurrentIndex ?? widget.queue!.currentIndex ?? 0;
|
||||
|
||||
return ListView.builder(
|
||||
key: const PageStorageKey('queue_list'),
|
||||
padding: Spacing.paddingH8,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false, // Items don't need individual keep-alive
|
||||
addRepaintBoundaries: false, // We add RepaintBoundary manually
|
||||
itemCount: items.length,
|
||||
// Disable scrolling while dragging to prevent gesture conflict
|
||||
physics: _dragIndex != null
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
final item = _items[index];
|
||||
final isCurrentItem = index == currentIndex;
|
||||
final isPastItem = index < currentIndex;
|
||||
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
|
||||
final isDragging = _dragIndex == index;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Opacity(
|
||||
opacity: isPastItem ? 0.5 : 1.0,
|
||||
child: Container(
|
||||
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
|
||||
memCacheWidth: 176,
|
||||
memCacheHeight: 176,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => Container(
|
||||
color: textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item.track.name,
|
||||
style: TextStyle(
|
||||
color: isCurrentItem ? primaryColor : textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
|
||||
? Text(
|
||||
item.track.artists!.first.name,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
trailing: isCurrentItem
|
||||
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
|
||||
: null,
|
||||
onTap: () {
|
||||
// TODO: Jump to this track in queue
|
||||
// Music Assistant API doesn't currently expose a skip_to_index or play_queue_item endpoint
|
||||
// Would need to add API method like skipToQueueIndex(queueId, index) if MA supports it
|
||||
// Alternative: Could use multiple next() calls but that's inefficient and unreliable
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
return Opacity(
|
||||
opacity: isDragging ? 0.3 : 1.0,
|
||||
child: _buildDismissibleItem(item, index, isCurrentItem, isPastItem),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDismissibleItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
|
||||
return Dismissible(
|
||||
key: ValueKey(item.queueItemId),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade700,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: const Icon(Icons.delete_outline, color: Colors.white, size: 24),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
// Block dismissal if swipe started from screen edge (Android back gesture)
|
||||
if (lastTouchWasInEdgeZone) return false;
|
||||
// Don't allow dismissing the currently playing item
|
||||
return !isCurrentItem;
|
||||
},
|
||||
onDismissed: (direction) => _handleDelete(item, index),
|
||||
child: _buildQueueItemWithDragHandle(item, index, isCurrentItem, isPastItem),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueItemWithDragHandle(QueueItem item, int index, bool isCurrentItem, bool isPastItem) {
|
||||
return Builder(
|
||||
builder: (itemContext) => _buildQueueItemContent(
|
||||
item,
|
||||
index,
|
||||
isCurrentItem,
|
||||
isPastItem,
|
||||
dragHandle: isCurrentItem
|
||||
? SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: Icon(Icons.play_arrow_rounded, color: widget.primaryColor, size: 20),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (event) {
|
||||
_startDrag(index, itemContext, event.position);
|
||||
},
|
||||
// Move/up/cancel handled by parent Listener on Stack
|
||||
child: Center(
|
||||
child: Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueItemContent(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {Widget? dragHandle}) {
|
||||
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
|
||||
final duration = _formatDuration(item.track.duration);
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Opacity(
|
||||
opacity: isPastItem ? 0.5 : 1.0,
|
||||
child: Container(
|
||||
margin: isCurrentItem ? const EdgeInsets.symmetric(horizontal: 8, vertical: 2) : EdgeInsets.zero,
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentItem ? widget.primaryColor.withOpacity(0.15) : widget.backgroundColor,
|
||||
borderRadius: isCurrentItem ? BorderRadius.circular(12) : BorderRadius.zero,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
// Compensate for current item's 8px margin on both sides
|
||||
// Left: Non-current: 8+0+16=24px, Current: 8+8+8=24px
|
||||
// Right: Non-current: 8+0+8+14=30px, Current: 8+8+0+14=30px
|
||||
contentPadding: EdgeInsets.only(
|
||||
left: isCurrentItem ? 8 : 16,
|
||||
right: isCurrentItem ? 0 : 8,
|
||||
),
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 176,
|
||||
memCacheHeight: 176,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => Container(
|
||||
color: widget.textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: widget.textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: widget.textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item.track.name,
|
||||
style: TextStyle(
|
||||
color: isCurrentItem ? widget.primaryColor : widget.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
|
||||
? Text(
|
||||
item.track.artists!.first.name,
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (duration.isNotEmpty)
|
||||
Text(
|
||||
duration,
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withOpacity(0.5),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
dragHandle ?? Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
],
|
||||
),
|
||||
onTap: () => _handleTapToSkip(item, index, isCurrentItem),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class for velocity tracking samples
|
||||
class _VelocitySample {
|
||||
final Offset position;
|
||||
final int timeMs;
|
||||
|
||||
_VelocitySample(this.position, this.timeMs);
|
||||
}
|
||||
|
||||
@@ -226,8 +226,8 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this player is part of a sync group
|
||||
final isGrouped = player.isGrouped;
|
||||
// Check if this player is manually synced (not a pre-configured group)
|
||||
final isGrouped = maProvider.isPlayerManuallySynced(player.playerId);
|
||||
// Pastel yellow for grouped players
|
||||
const groupBorderColor = Color(0xFFFFF59D);
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../screens/playlist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
|
||||
class PlaylistCard extends StatelessWidget {
|
||||
final Playlist playlist;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
final int? imageCacheSize;
|
||||
|
||||
const PlaylistCard({
|
||||
super.key,
|
||||
required this.playlist,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
final cacheSize = imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Playlist artwork
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Playlist title
|
||||
Hero(
|
||||
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Owner/track count
|
||||
Text(
|
||||
playlist.owner ?? (playlist.trackCount != null ? '${playlist.trackCount} tracks' : ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import 'playlist_card.dart';
|
||||
|
||||
class PlaylistRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<Playlist>> Function() loadPlaylists;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
final List<Playlist>? Function()? getCachedPlaylists;
|
||||
|
||||
const PlaylistRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadPlaylists,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedPlaylists,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlaylistRow> createState() => _PlaylistRowState();
|
||||
}
|
||||
|
||||
class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClientMixin {
|
||||
List<Playlist> _playlists = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedPlaylists?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_playlists = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadPlaylists();
|
||||
}
|
||||
|
||||
Future<void> _loadPlaylists() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data
|
||||
try {
|
||||
final freshPlaylists = await widget.loadPlaylists();
|
||||
if (mounted && freshPlaylists.isNotEmpty) {
|
||||
setState(() {
|
||||
_playlists = freshPlaylists;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precachePlaylistImages(freshPlaylists);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _precachePlaylistImages(List<Playlist> playlists) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final playlistsToCache = playlists.take(10);
|
||||
|
||||
for (final playlist in playlistsToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
// Don't render if empty and done loading
|
||||
if (_playlists.isEmpty && !_isLoading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Calculate card dimensions
|
||||
// Default: 44 for title, remaining for content (artwork + 2 lines of text)
|
||||
final rowHeight = widget.rowHeight ?? 237.0;
|
||||
final titleHeight = 44.0;
|
||||
final contentHeight = rowHeight - titleHeight;
|
||||
// Artwork is square, text takes ~50px (8 padding + title + owner)
|
||||
final artworkSize = contentHeight - 50;
|
||||
final cardWidth = artworkSize;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section title
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Horizontal list of playlists
|
||||
SizedBox(
|
||||
height: contentHeight,
|
||||
child: _isLoading && _playlists.isEmpty
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _playlists.length,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = _playlists[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: SizedBox(
|
||||
width: cardWidth,
|
||||
child: PlaylistCard(
|
||||
playlist: playlist,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
|
||||
class RadioStationCard extends StatelessWidget {
|
||||
final MediaItem radioStation;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
final int? imageCacheSize;
|
||||
|
||||
const RadioStationCard({
|
||||
super.key,
|
||||
required this.radioStation,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(radioStation, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
final cacheSize = imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
HapticFeedback.mediumImpact();
|
||||
// Play the radio station on selected player
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
if (selectedPlayer != null) {
|
||||
maProvider.api?.playRadioStation(selectedPlayer.playerId, radioStation);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Radio station artwork - circular for radio
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.radioCover + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Radio station name - centered for circular images
|
||||
Hero(
|
||||
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
radioStation.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import 'radio_station_card.dart';
|
||||
|
||||
class RadioStationRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<MediaItem>> Function() loadRadioStations;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
final List<MediaItem>? Function()? getCachedRadioStations;
|
||||
|
||||
const RadioStationRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadRadioStations,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedRadioStations,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RadioStationRow> createState() => _RadioStationRowState();
|
||||
}
|
||||
|
||||
class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAliveClientMixin {
|
||||
List<MediaItem> _radioStations = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedRadioStations?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_radioStations = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadRadioStations();
|
||||
}
|
||||
|
||||
Future<void> _loadRadioStations() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data
|
||||
try {
|
||||
final freshStations = await widget.loadRadioStations();
|
||||
if (mounted && freshStations.isNotEmpty) {
|
||||
setState(() {
|
||||
_radioStations = freshStations;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precacheRadioImages(freshStations);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _precacheRadioImages(List<MediaItem> stations) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final stationsToCache = stations.take(10);
|
||||
|
||||
for (final station in stationsToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(station, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
// Don't render if empty and done loading
|
||||
if (_radioStations.isEmpty && !_isLoading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Calculate card dimensions - similar to ArtistRow for circular items
|
||||
// Default: 44 for title, remaining for content (circular image + name)
|
||||
final rowHeight = widget.rowHeight ?? 207.0;
|
||||
final titleHeight = 44.0;
|
||||
final contentHeight = rowHeight - titleHeight;
|
||||
// Circular artwork, text takes ~40px (8 padding + name lines)
|
||||
final artworkSize = contentHeight - 40;
|
||||
final cardWidth = artworkSize;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section title
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Horizontal list of radio stations
|
||||
SizedBox(
|
||||
height: contentHeight,
|
||||
child: _isLoading && _radioStations.isEmpty
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _radioStations.length,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
itemBuilder: (context, index) {
|
||||
final station = _radioStations[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: SizedBox(
|
||||
width: cardWidth,
|
||||
child: RadioStationCard(
|
||||
radioStation: station,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,15 @@ class SeriesRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<AudiobookSeries>> Function() loadSeries;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<AudiobookSeries>? Function()? getCachedSeries;
|
||||
|
||||
const SeriesRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadSeries,
|
||||
this.rowHeight,
|
||||
this.getCachedSeries,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -25,8 +28,8 @@ class SeriesRow extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<AudiobookSeries>> _seriesFuture;
|
||||
List<AudiobookSeries>? _cachedSeries;
|
||||
List<AudiobookSeries> _series = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
// Cache for series cover images
|
||||
@@ -43,16 +46,34 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSeriesOnce();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedSeries?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_series = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadSeries();
|
||||
}
|
||||
|
||||
void _loadSeriesOnce() {
|
||||
if (!_hasLoaded) {
|
||||
_seriesFuture = widget.loadSeries().then((series) {
|
||||
_cachedSeries = series;
|
||||
return series;
|
||||
});
|
||||
_hasLoaded = true;
|
||||
Future<void> _loadSeries() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data (always update)
|
||||
try {
|
||||
final freshSeries = await widget.loadSeries();
|
||||
if (mounted && freshSeries.isNotEmpty) {
|
||||
setState(() {
|
||||
_series = freshSeries;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +99,12 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
_seriesCovers[seriesId] = covers;
|
||||
_seriesBookCounts[seriesId] = books.length;
|
||||
});
|
||||
// Extract colors asynchronously
|
||||
_extractSeriesColors(seriesId, covers);
|
||||
// Precache images for smooth hero animations
|
||||
_precacheSeriesCovers(covers);
|
||||
// Extract colors asynchronously (delayed to avoid jank during scroll)
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) _extractSeriesColors(seriesId, covers);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -87,6 +112,17 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
}
|
||||
}
|
||||
|
||||
/// Precache series cover images for smooth hero animations
|
||||
void _precacheSeriesCovers(List<String> covers) {
|
||||
if (!mounted) return;
|
||||
for (final url in covers) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(url),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract colors from series book covers for empty cell backgrounds
|
||||
Future<void> _extractSeriesColors(String seriesId, List<String> covers) async {
|
||||
if (_seriesExtractedColors.containsKey(seriesId) || covers.isEmpty) return;
|
||||
@@ -127,10 +163,68 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme, TextTheme textTheme, MusicAssistantProvider maProvider) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_series.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_series.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No series found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const textAreaHeight = 44.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _series.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
addRepaintBoundaries: false,
|
||||
itemBuilder: (context, index) {
|
||||
final s = _series[index];
|
||||
|
||||
// Load covers if not cached
|
||||
if (!_seriesCovers.containsKey(s.id) && !_loadingCovers.contains(s.id)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadSeriesCovers(s.id, maProvider);
|
||||
});
|
||||
}
|
||||
|
||||
return Container(
|
||||
key: ValueKey(s.id),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: _SeriesCard(
|
||||
series: s,
|
||||
covers: _seriesCovers[s.id],
|
||||
cachedBookCount: _seriesBookCounts[s.id],
|
||||
extractedColors: _seriesExtractedColors[s.id],
|
||||
colorScheme: colorScheme,
|
||||
textTheme: textTheme,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('SeriesRow:${widget.title}');
|
||||
super.build(context);
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
@@ -156,73 +250,7 @@ class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixi
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<AudiobookSeries>>(
|
||||
future: _seriesFuture,
|
||||
builder: (context, snapshot) {
|
||||
final series = snapshot.data ?? _cachedSeries;
|
||||
|
||||
if (series == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError && series == null) {
|
||||
return Center(
|
||||
child: Text('Error: ${snapshot.error}'),
|
||||
);
|
||||
}
|
||||
|
||||
if (series == null || series.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No series found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const textAreaHeight = 44.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize;
|
||||
final itemExtent = cardWidth + 12;
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: series.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
addRepaintBoundaries: false,
|
||||
itemBuilder: (context, index) {
|
||||
final s = series[index];
|
||||
|
||||
// Load covers if not cached
|
||||
if (!_seriesCovers.containsKey(s.id) && !_loadingCovers.contains(s.id)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadSeriesCovers(s.id, maProvider);
|
||||
});
|
||||
}
|
||||
|
||||
return Container(
|
||||
key: ValueKey(s.id),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: _SeriesCard(
|
||||
series: s,
|
||||
covers: _seriesCovers[s.id],
|
||||
cachedBookCount: _seriesBookCounts[s.id],
|
||||
extractedColors: _seriesExtractedColors[s.id],
|
||||
colorScheme: colorScheme,
|
||||
textTheme: textTheme,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildContent(contentHeight, colorScheme, textTheme, maProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -286,49 +314,14 @@ class _SeriesCard extends StatelessWidget {
|
||||
// Use AspectRatio to guarantee square cover
|
||||
Hero(
|
||||
tag: heroTag,
|
||||
// PERF: Use flightShuttleBuilder to avoid animating complex grid
|
||||
// during hero flight. Show cached first cover image instead.
|
||||
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
|
||||
// Use first cover as simple placeholder during flight
|
||||
final heroChild = toContext.widget as Hero;
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8 + 4 * animation.value),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: covers != null && covers!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: covers!.first,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark_rounded,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildCoverGrid(),
|
||||
// RepaintBoundary caches the rendered grid for smooth animation
|
||||
child: RepaintBoundary(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12), // Match detail screen
|
||||
child: _buildCoverGrid(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,12 +9,15 @@ class TrackRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<Track>> Function() loadTracks;
|
||||
final double? rowHeight;
|
||||
/// Optional: synchronous getter for cached data (for instant display)
|
||||
final List<Track>? Function()? getCachedTracks;
|
||||
|
||||
const TrackRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadTracks,
|
||||
this.rowHeight,
|
||||
this.getCachedTracks,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -22,8 +25,8 @@ class TrackRow extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin {
|
||||
late Future<List<Track>> _tracksFuture;
|
||||
List<Track>? _cachedTracks;
|
||||
List<Track> _tracks = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
@@ -32,25 +35,111 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTracksOnce();
|
||||
// Get cached data synchronously BEFORE first build (no spinner flash)
|
||||
final cached = widget.getCachedTracks?.call();
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_tracks = cached;
|
||||
_isLoading = false;
|
||||
}
|
||||
_loadTracks();
|
||||
}
|
||||
|
||||
void _loadTracksOnce() {
|
||||
if (!_hasLoaded) {
|
||||
_tracksFuture = widget.loadTracks().then((tracks) {
|
||||
_cachedTracks = tracks;
|
||||
return tracks;
|
||||
});
|
||||
_hasLoaded = true;
|
||||
Future<void> _loadTracks() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// Load fresh data (always update)
|
||||
try {
|
||||
final freshTracks = await widget.loadTracks();
|
||||
if (mounted && freshTracks.isNotEmpty) {
|
||||
setState(() {
|
||||
_tracks = freshTracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth scrolling
|
||||
_precacheTrackImages(freshTracks);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _precacheTrackImages(List<Track> tracks) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final tracksToCache = tracks.take(10);
|
||||
|
||||
for (final track in tracksToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(track, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final _logger = DebugLogger();
|
||||
|
||||
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||
// Only show loading if we have no data at all
|
||||
if (_tracks.isEmpty && _isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_tracks.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No tracks found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Card layout: square artwork + text below
|
||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12;
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _tracks.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||
itemBuilder: (context, index) {
|
||||
final track = _tracks[index];
|
||||
return Container(
|
||||
key: ValueKey(track.uri ?? track.itemId),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: _TrackCard(
|
||||
track: track,
|
||||
tracks: _tracks,
|
||||
index: index,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.startBuild('TrackRow:${widget.title}');
|
||||
super.build(context);
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
@@ -76,64 +165,7 @@ class _TrackRowState extends State<TrackRow> with AutomaticKeepAliveClientMixin
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<Track>>(
|
||||
future: _tracksFuture,
|
||||
builder: (context, snapshot) {
|
||||
final tracks = snapshot.data ?? _cachedTracks;
|
||||
|
||||
if (tracks == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError && tracks == null) {
|
||||
return Center(
|
||||
child: Text('Error: ${snapshot.error}'),
|
||||
);
|
||||
}
|
||||
|
||||
if (tracks == null || tracks.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No tracks found',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Card layout: square artwork + text below
|
||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||
const textAreaHeight = 44.0;
|
||||
final artworkSize = contentHeight - textAreaHeight;
|
||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||
final itemExtent = cardWidth + 12;
|
||||
|
||||
return ScrollConfiguration(
|
||||
behavior: const _StretchScrollBehavior(),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: tracks.length,
|
||||
itemExtent: itemExtent,
|
||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||
itemBuilder: (context, index) {
|
||||
final track = tracks[index];
|
||||
return Container(
|
||||
key: ValueKey(track.uri ?? track.itemId),
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
child: _TrackCard(
|
||||
track: track,
|
||||
tracks: tracks,
|
||||
index: index,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildContent(contentHeight, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
@@ -11,8 +12,9 @@ import '../services/settings_service.dart';
|
||||
/// For MA players: controls player volume via API
|
||||
class VolumeControl extends StatefulWidget {
|
||||
final bool compact;
|
||||
final Color? accentColor;
|
||||
|
||||
const VolumeControl({super.key, this.compact = false});
|
||||
const VolumeControl({super.key, this.compact = false, this.accentColor});
|
||||
|
||||
@override
|
||||
State<VolumeControl> createState() => _VolumeControlState();
|
||||
@@ -25,11 +27,34 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
StreamSubscription? _volumeListener;
|
||||
String? _localPlayerId;
|
||||
bool _isLocalPlayer = false;
|
||||
bool _isDragging = false;
|
||||
|
||||
// Live volume update state
|
||||
int _lastVolumeUpdateTime = 0;
|
||||
static const int _volumeThrottleMs = 150;
|
||||
static const int _precisionThrottleMs = 50;
|
||||
|
||||
// Precision mode state
|
||||
bool _inPrecisionMode = false;
|
||||
Timer? _precisionTimer;
|
||||
Offset? _lastDragPosition;
|
||||
double _lastLocalX = 0.0;
|
||||
bool _precisionModeEnabled = true;
|
||||
double _precisionZoomCenter = 0.0;
|
||||
double _precisionStartX = 0.0;
|
||||
static const int _precisionTriggerMs = 800;
|
||||
static const double _precisionStillnessThreshold = 5.0;
|
||||
static const double _precisionSensitivity = 0.1; // 10% range in precision mode
|
||||
|
||||
// Button tap indicator state
|
||||
bool _showButtonIndicator = false;
|
||||
Timer? _buttonIndicatorTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initLocalPlayer();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _initLocalPlayer() async {
|
||||
@@ -43,7 +68,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
}
|
||||
|
||||
_volumeListener = FlutterVolumeController.addListener((volume) {
|
||||
if (mounted && _isLocalPlayer) {
|
||||
if (mounted && _isLocalPlayer && !_isDragging) {
|
||||
setState(() {
|
||||
_systemVolume = volume;
|
||||
});
|
||||
@@ -51,12 +76,99 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final precisionEnabled = await SettingsService.getVolumePrecisionMode();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_precisionModeEnabled = precisionEnabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _enterPrecisionMode() {
|
||||
if (_inPrecisionMode) return;
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
_inPrecisionMode = true;
|
||||
_precisionZoomCenter = _pendingVolume ?? 0.5;
|
||||
_precisionStartX = _lastLocalX;
|
||||
});
|
||||
}
|
||||
|
||||
void _exitPrecisionMode() {
|
||||
_precisionTimer?.cancel();
|
||||
_precisionTimer = null;
|
||||
if (_inPrecisionMode) {
|
||||
setState(() {
|
||||
_inPrecisionMode = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_volumeListener?.cancel();
|
||||
_precisionTimer?.cancel();
|
||||
_buttonIndicatorTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _adjustVolume(MusicAssistantProvider maProvider, String playerId, double currentVolume, int delta) async {
|
||||
final newVolume = ((currentVolume * 100).round() + delta).clamp(0, 100);
|
||||
|
||||
// Show indicator briefly on button tap
|
||||
_buttonIndicatorTimer?.cancel();
|
||||
setState(() {
|
||||
_showButtonIndicator = true;
|
||||
_pendingVolume = newVolume / 100.0;
|
||||
});
|
||||
_buttonIndicatorTimer = Timer(const Duration(milliseconds: 800), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showButtonIndicator = false;
|
||||
_pendingVolume = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (_isLocalPlayer) {
|
||||
_logger.log('Volume: Setting system volume to $newVolume%');
|
||||
try {
|
||||
await FlutterVolumeController.setVolume(newVolume / 100.0);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_systemVolume = newVolume / 100.0;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Volume: Error setting system volume - $e');
|
||||
}
|
||||
} else {
|
||||
_logger.log('Volume: Setting to $newVolume%');
|
||||
try {
|
||||
await maProvider.setVolume(playerId, newVolume);
|
||||
} catch (e) {
|
||||
_logger.log('Volume: Error - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _sendVolumeUpdate(MusicAssistantProvider maProvider, String playerId, double volume) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final throttleMs = _inPrecisionMode ? _precisionThrottleMs : _volumeThrottleMs;
|
||||
|
||||
if (now - _lastVolumeUpdateTime >= throttleMs) {
|
||||
_lastVolumeUpdateTime = now;
|
||||
final volumeLevel = (volume * 100).round();
|
||||
|
||||
if (_isLocalPlayer) {
|
||||
FlutterVolumeController.setVolume(volume);
|
||||
} else {
|
||||
maProvider.setVolume(playerId, volumeLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
@@ -68,118 +180,262 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
|
||||
_isLocalPlayer = _localPlayerId != null && player.playerId == _localPlayerId;
|
||||
|
||||
final currentVolume = _isLocalPlayer
|
||||
? (_pendingVolume ?? (_systemVolume ?? 0.5))
|
||||
: (_pendingVolume ?? player.volume.toDouble()) / 100.0;
|
||||
final isMuted = _isLocalPlayer ? false : player.isMuted;
|
||||
// Use pending volume during drag or button tap to prevent stale state issues
|
||||
final currentVolume = (_isDragging || _showButtonIndicator)
|
||||
? (_pendingVolume ?? (_isLocalPlayer ? (_systemVolume ?? 0.5) : player.volume.toDouble() / 100.0))
|
||||
: _isLocalPlayer
|
||||
? (_systemVolume ?? 0.5)
|
||||
: player.volume.toDouble() / 100.0;
|
||||
|
||||
// Use accent color if provided, otherwise fall back to theme primary
|
||||
final accentColor = widget.accentColor ?? Theme.of(context).colorScheme.primary;
|
||||
|
||||
if (widget.compact) {
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
isMuted ? Icons.volume_off : Icons.volume_up,
|
||||
currentVolume < 0.01
|
||||
? Icons.volume_off
|
||||
: currentVolume < 0.3
|
||||
? Icons.volume_down
|
||||
: Icons.volume_up,
|
||||
color: Colors.white70,
|
||||
),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.setMute(player.playerId, !isMuted);
|
||||
} catch (e) {
|
||||
// Error already logged by provider
|
||||
}
|
||||
// In compact mode, toggle between 0 and 50%
|
||||
final newVolume = currentVolume < 0.01 ? 50 : 0;
|
||||
await _adjustVolume(maProvider, player.playerId, newVolume / 100.0, 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Volume decrease button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isMuted
|
||||
? Icons.volume_off
|
||||
: currentVolume < 0.3
|
||||
? Icons.volume_down
|
||||
: Icons.volume_up,
|
||||
icon: const Icon(
|
||||
Icons.volume_down,
|
||||
color: Colors.white70,
|
||||
),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await maProvider.setMute(player.playerId, !isMuted);
|
||||
} catch (e) {
|
||||
// Error already logged by provider
|
||||
}
|
||||
},
|
||||
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, -1),
|
||||
),
|
||||
// Slider with floating teardrop indicator
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48, // Increase touch target height
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||
activeTrackColor: Colors.white,
|
||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
||||
thumbColor: Colors.white,
|
||||
overlayColor: Colors.white.withOpacity(0.2),
|
||||
),
|
||||
child: Slider(
|
||||
value: currentVolume.clamp(0.0, 1.0),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_pendingVolume = _isLocalPlayer ? value : value * 100;
|
||||
});
|
||||
height: 48,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sliderWidth = constraints.maxWidth;
|
||||
// Calculate thumb position to match Flutter Slider's internal positioning
|
||||
// The Slider uses overlayRadius as padding on each side for the track
|
||||
const overlayRadius = 20.0; // Must match SliderThemeData overlayShape
|
||||
final thumbPosition = overlayRadius +
|
||||
(currentVolume.clamp(0.0, 1.0) * (sliderWidth - 2 * overlayRadius));
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// The slider with gesture detection for precision mode
|
||||
GestureDetector(
|
||||
onHorizontalDragStart: (details) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_pendingVolume = currentVolume;
|
||||
_lastDragPosition = details.globalPosition;
|
||||
_lastLocalX = details.localPosition.dx;
|
||||
});
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!_isDragging) return;
|
||||
|
||||
final currentPosition = details.globalPosition;
|
||||
|
||||
// Check for stillness to trigger precision mode
|
||||
if (_precisionModeEnabled && _lastDragPosition != null) {
|
||||
final movement = (currentPosition - _lastDragPosition!).distance;
|
||||
|
||||
if (movement < _precisionStillnessThreshold) {
|
||||
// Finger is still - start precision timer
|
||||
if (_precisionTimer == null && !_inPrecisionMode) {
|
||||
_precisionTimer = Timer(
|
||||
Duration(milliseconds: _precisionTriggerMs),
|
||||
_enterPrecisionMode,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Finger moved - cancel timer
|
||||
_precisionTimer?.cancel();
|
||||
_precisionTimer = null;
|
||||
}
|
||||
}
|
||||
_lastDragPosition = currentPosition;
|
||||
_lastLocalX = details.localPosition.dx;
|
||||
|
||||
double newVolume;
|
||||
|
||||
if (_inPrecisionMode) {
|
||||
// PRECISION MODE: Movement from entry point maps to zoomed range
|
||||
final offsetX = details.localPosition.dx - _precisionStartX;
|
||||
final normalizedOffset = offsetX / sliderWidth;
|
||||
final volumeChange = normalizedOffset * _precisionSensitivity;
|
||||
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
|
||||
} else {
|
||||
// NORMAL MODE: Position-based (like standard slider)
|
||||
newVolume = (details.localPosition.dx / sliderWidth).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
if ((newVolume - (_pendingVolume ?? 0)).abs() > 0.001) {
|
||||
setState(() {
|
||||
_pendingVolume = newVolume;
|
||||
});
|
||||
_sendVolumeUpdate(maProvider, player.playerId, newVolume);
|
||||
}
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (!_isDragging) return;
|
||||
|
||||
// Send final volume
|
||||
final finalVolume = _pendingVolume ?? currentVolume;
|
||||
final volumeLevel = (finalVolume * 100).round();
|
||||
|
||||
if (_isLocalPlayer) {
|
||||
FlutterVolumeController.setVolume(finalVolume);
|
||||
_systemVolume = finalVolume;
|
||||
} else {
|
||||
maProvider.setVolume(player.playerId, volumeLevel);
|
||||
}
|
||||
|
||||
_exitPrecisionMode();
|
||||
_lastDragPosition = null;
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
_pendingVolume = null;
|
||||
});
|
||||
},
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||
activeTrackColor: _inPrecisionMode ? accentColor : Colors.white,
|
||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
||||
thumbColor: _inPrecisionMode ? accentColor : Colors.white,
|
||||
overlayColor: (_inPrecisionMode ? accentColor : Colors.white).withOpacity(0.2),
|
||||
),
|
||||
child: AbsorbPointer(
|
||||
// AbsorbPointer prevents Slider from handling gestures
|
||||
// Our GestureDetector handles everything for live updates
|
||||
child: Slider(
|
||||
value: currentVolume.clamp(0.0, 1.0),
|
||||
onChanged: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Floating teardrop indicator (visible when dragging or button tap)
|
||||
if (_isDragging || _showButtonIndicator)
|
||||
Positioned(
|
||||
left: thumbPosition - 16,
|
||||
top: -28,
|
||||
child: CustomPaint(
|
||||
size: const Size(32, 32),
|
||||
painter: _TeardropPainter(
|
||||
color: _inPrecisionMode ? accentColor : Colors.white,
|
||||
volume: (currentVolume * 100).round(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
onChangeEnd: (value) async {
|
||||
if (_isLocalPlayer) {
|
||||
_logger.log('Volume: Setting system volume to ${(value * 100).round()}%');
|
||||
try {
|
||||
await FlutterVolumeController.setVolume(value);
|
||||
_systemVolume = value;
|
||||
} catch (e) {
|
||||
_logger.log('Volume: Error setting system volume - $e');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingVolume = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final volumeLevel = (value * 100).round();
|
||||
_logger.log('Volume: Setting to $volumeLevel%');
|
||||
try {
|
||||
if (isMuted) {
|
||||
_logger.log('Volume: Unmuting player first');
|
||||
await maProvider.setMute(player.playerId, false);
|
||||
}
|
||||
await maProvider.setVolume(player.playerId, volumeLevel);
|
||||
_logger.log('Volume: Set to $volumeLevel%');
|
||||
} catch (e) {
|
||||
_logger.log('Volume: Error - $e');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_pendingVolume = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
'${(currentVolume * 100).round()}%',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
// Volume increase button
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.volume_up,
|
||||
color: Colors.white70,
|
||||
),
|
||||
onPressed: () => _adjustVolume(maProvider, player.playerId, currentVolume, 1),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom painter for upside-down teardrop volume indicator
|
||||
class _TeardropPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final int volume;
|
||||
|
||||
_TeardropPainter({required this.color, required this.volume});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width / 2 - 2;
|
||||
|
||||
// Draw upside-down teardrop shape
|
||||
final path = Path();
|
||||
|
||||
// Start at bottom point (the tip pointing down toward slider)
|
||||
path.moveTo(center.dx, size.height - 2);
|
||||
|
||||
// Curve up to the left side of the circle
|
||||
path.quadraticBezierTo(
|
||||
center.dx - radius * 0.8,
|
||||
center.dy + radius * 0.3,
|
||||
center.dx - radius,
|
||||
center.dy - radius * 0.2,
|
||||
);
|
||||
|
||||
// Arc around the top (the bulb)
|
||||
path.arcToPoint(
|
||||
Offset(center.dx + radius, center.dy - radius * 0.2),
|
||||
radius: Radius.circular(radius),
|
||||
clockwise: true,
|
||||
);
|
||||
|
||||
// Curve down to the bottom point
|
||||
path.quadraticBezierTo(
|
||||
center.dx + radius * 0.8,
|
||||
center.dy + radius * 0.3,
|
||||
center.dx,
|
||||
size.height - 2,
|
||||
);
|
||||
|
||||
path.close();
|
||||
canvas.drawPath(path, paint);
|
||||
|
||||
// Draw the volume number (no % symbol)
|
||||
final textColor = color.computeLuminance() > 0.5 ? Colors.black87 : Colors.white;
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '$volume',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
|
||||
// Position text in the center of the bulb (slightly above center)
|
||||
final textOffset = Offset(
|
||||
center.dx - textPainter.width / 2,
|
||||
center.dy - radius * 0.4 - textPainter.height / 2,
|
||||
);
|
||||
textPainter.paint(canvas, textOffset);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _TeardropPainter oldDelegate) {
|
||||
return oldDelegate.color != color || oldDelegate.volume != volume;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.7.3-beta
|
||||
version: 2.8.6-beta+44
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -28,6 +28,7 @@ dependencies:
|
||||
|
||||
# Storage
|
||||
shared_preferences: ^2.2.2
|
||||
flutter_secure_storage: ^9.2.2
|
||||
drift: ^2.22.0
|
||||
drift_flutter: 0.2.0
|
||||
path_provider: ^2.1.5
|
||||
|
||||