Compare commits
@@ -31,10 +31,20 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.24.0'
|
||||
channel: 'stable'
|
||||
flutter-version: '3.38.0'
|
||||
channel: 'master'
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
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
|
||||
|
||||
@@ -53,10 +63,7 @@ jobs:
|
||||
|
||||
- name: Setup signing keystore
|
||||
run: |
|
||||
# Decode keystore from secret (persistent key for consistent signatures)
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
|
||||
|
||||
# Create key.properties file
|
||||
cat > android/key.properties << EOF
|
||||
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword=${{ secrets.KEY_PASSWORD }}
|
||||
@@ -76,10 +83,5 @@ jobs:
|
||||
|
||||
- name: Upload APK summary
|
||||
run: |
|
||||
echo "## APK Build Successful! 🎉" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📱 **APK Location:** \`build/app/outputs/flutter-apk/app-release.apk\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📦 **Download:** Check the artifacts section below" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## APK Build Successful!" >> $GITHUB_STEP_SUMMARY
|
||||
ls -lh build/app/outputs/flutter-apk/app-release.apk >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -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,257 @@
|
||||
# Ensemble Codebase Audit Report
|
||||
|
||||
**Date**: 2026-01-14
|
||||
**Scope**: Full codebase audit (104 Dart files, 18 screens)
|
||||
**Focus Areas**: UI Continuity, Performance, Logic/Best Practices
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Ensemble codebase is a **mature, feature-rich Flutter application** with many good patterns already in place. However, the audit identified **significant architectural debt** that should be addressed:
|
||||
|
||||
| Severity | Count | Key Themes |
|
||||
|----------|-------|------------|
|
||||
| **Critical** | 6 | God classes (5,443 & 4,209 lines), design token adoption, startup blocking |
|
||||
| **High** | 15 | Touch targets, batch DB operations, over-notification, code duplication |
|
||||
| **Medium** | 25+ | Inconsistencies, memory issues, missing error boundaries |
|
||||
| **Low** | 15+ | Minor polish items |
|
||||
|
||||
**Top 3 Priorities:**
|
||||
1. **Split `MusicAssistantProvider` (5,443 lines)** - God object handling everything
|
||||
2. **Split `new_library_screen.dart` (4,209 lines)** - 10 scroll controllers, 15+ view modes
|
||||
3. **Fix startup performance** - Sequential blocking ops could save 1-8 seconds
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### 1. God Object: MusicAssistantProvider (5,443 lines)
|
||||
**File**: `lib/providers/music_assistant_provider.dart`
|
||||
- 54 `notifyListeners()` calls
|
||||
- 30+ state variables
|
||||
- 100+ public methods
|
||||
- Handles: connection, auth, players, library, search, caching, local playback, Sendspin
|
||||
- **Fix**: Split into ConnectionManager, PlayerStateManager, LibraryStateManager, SearchStateManager
|
||||
|
||||
### 2. God Object: NewLibraryScreen (4,209 lines)
|
||||
**File**: `lib/screens/new_library_screen.dart`
|
||||
- 10 scroll controllers
|
||||
- 15+ view mode states
|
||||
- 15+ sort order states
|
||||
- Handles: Music, Books, Podcasts, Radio tabs
|
||||
- **Fix**: Extract to ArtistsTab, AlbumsTab, TracksTab, PlaylistsTab, BooksTab, PodcastsTab, RadioTab
|
||||
|
||||
### 3. Sequential Blocking Startup (500-1500ms wasted)
|
||||
**File**: `lib/main.dart:33-66`
|
||||
```dart
|
||||
await DatabaseService.instance.initialize();
|
||||
await ProfileService.instance.migrateFromOwnerName();
|
||||
await SettingsService.migrateToSecureStorage();
|
||||
await SyncService.instance.loadFromCache();
|
||||
audioHandler = await AudioService.init(...);
|
||||
```
|
||||
- **Fix**: Parallelize with `Future.wait()`, defer AudioService to post-first-frame
|
||||
|
||||
### 4. Design Tokens Exist But Unused
|
||||
**File**: `lib/theme/design_tokens.dart`
|
||||
- `Spacing.*` used in only ~6 of 34+ widget files
|
||||
- `Radii.*` used in only ~5 files
|
||||
- `IconSizes.*` used in only ~4 files
|
||||
- **Fix**: Systematic replacement of hardcoded values
|
||||
|
||||
### 5. Touch Targets Too Small (32x32)
|
||||
**File**: `lib/screens/new_library_screen.dart:1805-1867`
|
||||
- Sort, favorites, view mode buttons are 32x32
|
||||
- Material Design minimum: 48x48
|
||||
- **Fix**: Increase to 48x48 or add padding for hit area
|
||||
|
||||
### 6. No Batch Database Inserts
|
||||
**File**: `lib/services/sync_service.dart:212-273`
|
||||
- 1000 albums = 1000 separate DB transactions
|
||||
- **Fix**: Wrap in single transaction or use Drift batch insert
|
||||
|
||||
---
|
||||
|
||||
## High Severity Issues
|
||||
|
||||
### Performance
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| `context.watch()` in nested tabs | new_library_screen.dart:2960 | All tabs rebuild on any provider change |
|
||||
| Consumer wrapping 800-line player | expandable_player.dart:1234 | Full rebuild on every state change |
|
||||
| Inline sorting during build | new_library_screen.dart:3647 | 5-20ms for 1000+ albums |
|
||||
| Multiple setState for author images | new_library_screen.dart:1017 | 20+ rebuilds as images load |
|
||||
| SecureStorage migration every startup | secure_storage_service.dart:103 | 100-300ms wasted |
|
||||
|
||||
### Architecture
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| State duplication across 4 providers | providers/*.dart | Maintenance burden, sync issues |
|
||||
| 8 duplicate `_cycle*ViewMode()` methods | new_library_screen.dart:251-402 | DRY violation |
|
||||
| 60+ duplicate getter/setter pairs | settings_service.dart:500-1000 | Code bloat |
|
||||
| Global singleton NavigationProvider | navigation_provider.dart:40 | Untestable |
|
||||
| CacheService duplicated in providers | Multiple | Memory waste, inconsistency |
|
||||
|
||||
### UI/UX
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| Hardcoded bottom spacing (140, 164, 100) | Multiple detail screens | Content obscured by player |
|
||||
| Inconsistent button sizes (44 vs 50) | Multiple screens | Visual disharmony |
|
||||
| No debounce on card taps | album_card.dart:118 | Double navigation possible |
|
||||
| Missing PopScope on AudiobookSeriesScreen | audiobook_series_screen.dart:276 | Adaptive colors not cleared |
|
||||
|
||||
### Error Handling
|
||||
| Issue | File | Impact |
|
||||
|-------|------|--------|
|
||||
| No Flutter error boundaries | main.dart | Red error screen on build failure |
|
||||
| API returns empty list on error | music_assistant_api.dart | Can't distinguish error from empty |
|
||||
|
||||
---
|
||||
|
||||
## Medium Severity Issues
|
||||
|
||||
### Performance
|
||||
- PaletteGenerator on main isolate (10-30ms blocking)
|
||||
- Multiple `SharedPreferences.getInstance()` calls (132 occurrences)
|
||||
- AppStartup polling loop with fixed 250ms delay
|
||||
- SystemThemeHelper FutureBuilder recreated on every build
|
||||
|
||||
### UI Consistency
|
||||
- Border radius varies: 8, 12, 20, 22, 25 for same element types
|
||||
- `SizedBox(height: 8)` appears 40+ times instead of `Spacing.vGap8`
|
||||
- Hardcoded colors for status (Colors.red, Colors.green)
|
||||
- Inconsistent icon sizes across screens
|
||||
|
||||
### State Management
|
||||
- Over-notification (multiple notifyListeners in single methods)
|
||||
- Wide Consumer scope (watch instead of select)
|
||||
- Missing dispose() in some providers
|
||||
- Inconsistent async state handling (loading/error)
|
||||
|
||||
### Navigation
|
||||
- `isQueuePanelOpen` vs `isQueuePanelTargetOpen` inconsistency
|
||||
- `popToHome()` doesn't clear adaptive colors
|
||||
- Infinite navigation loop possible (Album → Artist → Album)
|
||||
- Orphaned Hero animations in podcast_card, radio_station_card
|
||||
|
||||
### Data/Caching
|
||||
- Unbounded `_podcastCoverCache`
|
||||
- SyncService listener not removed on error
|
||||
- Redundant API calls between providers
|
||||
- Fire-and-forget persistence race condition
|
||||
|
||||
### Error Handling
|
||||
- No zone error handler for async exceptions
|
||||
- `retryCritical()` retries all errors including non-recoverable
|
||||
- No user notification of offline queue status
|
||||
- DisconnectedState not used consistently
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins (High Impact, Low Effort)
|
||||
|
||||
| # | Task | File(s) | Effort |
|
||||
|---|------|---------|--------|
|
||||
| 1 | Replace hardcoded bottom spacing with `BottomSpacing.withMiniPlayer` | Detail screens | 30 min |
|
||||
| 2 | Add navigation debounce to card widgets | album_card, artist_card, etc. | 1 hr |
|
||||
| 3 | Fix touch targets to 48x48 minimum | new_library_screen.dart | 15 min |
|
||||
| 4 | Add migration skip flag for SecureStorage | secure_storage_service.dart | 30 min |
|
||||
| 5 | Add Flutter error boundary | main.dart | 30 min |
|
||||
| 6 | Use `context.select()` instead of `context.watch()` | global_player_overlay, etc. | 2 hr |
|
||||
| 7 | Cache `SharedPreferences.getInstance()` | settings_service.dart | 30 min |
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Roadmap
|
||||
|
||||
### Phase 1: Quick Wins (1-2 days)
|
||||
- Fix touch targets
|
||||
- Fix bottom spacing
|
||||
- Add navigation debounce
|
||||
- Add error boundaries
|
||||
- Cache SharedPreferences singleton
|
||||
|
||||
### Phase 2: Performance (3-5 days)
|
||||
- Parallelize startup operations
|
||||
- Replace `context.watch()` with `context.select()`
|
||||
- Batch database inserts
|
||||
- Add SecureStorage migration skip flag
|
||||
- Fix over-notification in provider
|
||||
|
||||
### Phase 3: Design System (1 week)
|
||||
- Systematic design token adoption
|
||||
- Standardize button sizes (44 vs 50)
|
||||
- Standardize border radius values
|
||||
- Create StatusColors semantic tokens
|
||||
- Replace all magic numbers
|
||||
|
||||
### Phase 4: Architecture (2-3 weeks)
|
||||
- ~~Split MusicAssistantProvider into focused managers~~ **DEFERRED** - MusicAssistantProvider has evolved with Sendspin, Cast-Sendspin ID mapping, position tracker integration. Splitting would require ~1600 lines of sync work. Code is working and tested.
|
||||
- ~~Split new_library_screen into tab widgets~~ **DEFERRED** - Tabs have tight state coupling (15+ view modes, 15+ sort orders, 10 scroll controllers). Extraction would require InheritedWidget/callbacks for all shared state. Risk outweighs benefit.
|
||||
- ✅ Extract generic helpers for SettingsService - Reduced from 1,031 to 874 lines (~15%)
|
||||
- ✅ Remove/integrate unused providers - Deleted PlayerProvider (964 lines), LibraryProvider, ConnectionProvider (651 lines total)
|
||||
- ~~Split music_assistant_api.dart by domain~~ **DEFERRED** - All methods share WebSocket connection and auth state. Clean extraction would require connection manager abstraction.
|
||||
|
||||
---
|
||||
|
||||
## Positive Findings
|
||||
|
||||
The codebase already demonstrates many good practices:
|
||||
|
||||
- **Performance awareness**: `// PERF:` comments, RepaintBoundary (55 occurrences), image caching (80 memCacheWidth/Height)
|
||||
- **Caching**: Multi-tier caching with LRU eviction, proper TTLs, proper DB indexes
|
||||
- **Error handling**: DebugLogger, RetryHelper, OfflineActionQueue well-implemented
|
||||
- **Theme system**: Material You support, adaptive colors from album art
|
||||
- **Offline support**: Cache-first loading, graceful degradation
|
||||
- **Navigation**: Consistent FadeSlidePageRoute, proper Hero animations (mostly)
|
||||
|
||||
---
|
||||
|
||||
## Files by Priority
|
||||
|
||||
| Priority | File | Lines | Key Issues |
|
||||
|----------|------|-------|------------|
|
||||
| 1 | music_assistant_provider.dart | 5,443 | God object, over-notification |
|
||||
| 2 | new_library_screen.dart | 4,209 | God class, performance, touch targets |
|
||||
| 3 | main.dart | 549 | Startup blocking, error boundaries |
|
||||
| 4 | music_assistant_api.dart | 3,434 | Silent failures, needs splitting |
|
||||
| 5 | settings_service.dart | 1,019 | Code duplication, no caching |
|
||||
| 6 | expandable_player.dart | ~800 | Wide Consumer scope |
|
||||
| 7 | global_player_overlay.dart | ~500 | BottomSpacing source, Consumer scope |
|
||||
| 8 | All detail screens | Various | Hardcoded bottom spacing |
|
||||
| 9 | All card widgets | Various | Navigation debounce, design tokens |
|
||||
|
||||
---
|
||||
|
||||
## Audit Methodology
|
||||
|
||||
This audit was conducted using 9 parallel sub-agents analyzing:
|
||||
|
||||
**UI Continuity (3 agents)**
|
||||
1. Navigation & Flow - back button, Hero animations, tab preservation
|
||||
2. Visual Consistency - design tokens, spacing, typography
|
||||
3. Component Placement - touch targets, bottom spacing, modals
|
||||
|
||||
**Performance (3 agents)**
|
||||
4. Rendering Performance - ListView.builder, const, rebuilds
|
||||
5. Data & Caching - streams, invalidation, memory bounds
|
||||
6. Startup & Load Time - blocking operations, initialization order
|
||||
|
||||
**Logic/Best Practices (3 agents)**
|
||||
7. State Management - Provider patterns, notification frequency
|
||||
8. Error Handling - try-catch, boundaries, resilience
|
||||
9. Code Quality - file size, duplication, SOLID principles
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Ensemble app is well-architected at the macro level but has accumulated technical debt in its largest files. The most impactful improvements would be:
|
||||
|
||||
1. **Splitting the two 4,000+ line files** into focused components
|
||||
2. **Adopting design tokens systematically** for visual consistency
|
||||
3. **Optimizing startup sequence** for faster app launch
|
||||
4. **Using granular state management** (select vs watch) for better performance
|
||||
|
||||
The quick wins section provides immediate value with minimal risk, while the roadmap offers a path to sustainable improvement.
|
||||
@@ -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,66 @@ 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 left to Delete** - Remove tracks with a simple swipe gesture
|
||||
- **Swipe right to play next** - Move tracks with a simple swipe gesture
|
||||
|
||||
### Home Screen
|
||||
- **Customizable Rows** - Toggle 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
|
||||
- **Providers Filter** - Toggle to show only specific provider items
|
||||
- **Letter Scrollbar** - Fast navigation through long lists
|
||||
- **Provider Icons** - Provider icons on library covers
|
||||
|
||||
### Search
|
||||
- **Universal Search** - Find music, podcasts, radio stations, playlists, and audiobooks
|
||||
- **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 +89,18 @@ 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=4" alt="Screenshot 1" width="150">
|
||||
<img src="assets/screenshots/2.png?v=4" alt="Screenshot 2" width="150">
|
||||
<img src="assets/screenshots/3.png?v=4" alt="Screenshot 3" width="150">
|
||||
<img src="assets/screenshots/4.png?v=4" alt="Screenshot 4" width="150">
|
||||
<img src="assets/screenshots/5.png?v=4" alt="Screenshot 5" width="150">
|
||||
<img src="assets/screenshots/6.png?v=4" alt="Screenshot 6" width="150">
|
||||
<img src="assets/screenshots/7.png?v=4" alt="Screenshot 7" width="150">
|
||||
<img src="assets/screenshots/8.png?v=4" alt="Screenshot 8" width="150">
|
||||
<img src="assets/screenshots/9.png?v=4" alt="Screenshot 9" width="150">
|
||||
<img src="assets/screenshots/10.png?v=4" alt="Screenshot 10" width="150">
|
||||
<img src="assets/screenshots/11.png?v=4" alt="Screenshot 11" width="150">
|
||||
<img src="assets/screenshots/12.png?v=4" alt="Screenshot 12" width="150">
|
||||
</div>
|
||||
|
||||
## Download
|
||||
@@ -84,9 +110,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 +149,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)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Release Notes Draft
|
||||
|
||||
## v2.9.2-beta (TBD)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### Audiobooks
|
||||
- **Fixed audiobooks not loading after provider re-add**: When removing and re-adding an AudiobookShelf provider, the app would show 0 audiobooks because saved library paths referenced the old provider instance ID. Now automatically clears stale settings and falls back to the library_items API.
|
||||
|
||||
- **Fixed duplicate provider filter options**: After re-adding a provider, two entries for the same provider could appear in the library filter menu. Now deduplicates providers by name, keeping the one with more items.
|
||||
|
||||
- **Fixed multiple accounts of same service not showing**: When you have multiple accounts of the same service (e.g., two Spotify accounts for different family members), only one would appear in the library menu provider toggles. Now all accounts with distinct names are shown.
|
||||
|
||||
- **Fixed audiobook year sorting**: Sort by year now works correctly for audiobooks. Fixed two issues:
|
||||
- Year data is now properly extracted from ABS's `metadata.release_date` field (handles both "2024" and "2024-01-15" formats)
|
||||
- Sort handler now properly supports ascending and descending year sorts
|
||||
|
||||
#### Library Sorting
|
||||
- **Fixed letter scrollbar showing irrelevant letters**: When sorting by date or play count, the scrollbar popup now hides instead of showing meaningless letters. Shows contextual info based on sort type (letters for alphabetical, years for year sort, hidden for date/count sorts).
|
||||
|
||||
- **Fixed sorting not working for date added, last played, play count**: These sort options now correctly preserve server-side sorting instead of re-sorting alphabetically on the client. Affected albums, artists, and tracks tabs.
|
||||
|
||||
- **Fixed playlists sorting**: Modified, last played, and play count sorting now works for playlists by properly requesting sorted data from the server.
|
||||
|
||||
- **Fixed ascending/descending not working**: Sort direction now properly applies for all sort types across albums, artists, playlists, and audiobooks.
|
||||
|
||||
- **Fixed list not updating when sort order changes**: Added sort order to ListView/GridView keys to force UI rebuilds when changing sort direction.
|
||||
|
||||
#### Provider Management
|
||||
- **Fixed stale provider IDs persisting in settings**: When toggling providers, stale provider instance IDs (from removed providers) are now automatically cleaned up from saved settings.
|
||||
|
||||
### Technical Changes
|
||||
- Added `ScrollbarDisplayMode` enum (`letter`, `year`, `count`, `none`) for contextual scrollbar popups
|
||||
- Added `updateCachedAlbums()`, `updateCachedArtists()`, `updateCachedPlaylists()` methods to SyncService for proper cache synchronization with sorted data
|
||||
- Provider filter now validates against available providers when toggling, not just on initial load
|
||||
- Removed Selector caching from albums and artists tabs to ensure proper rebuilds when source tracking updates
|
||||
- Options menu provider toggles now use Consumer to watch Provider state directly
|
||||
@@ -0,0 +1,52 @@
|
||||
# Search Improvements
|
||||
|
||||
Branch: `feature/search-improvements`
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Add playlists and audiobooks to search results
|
||||
- Parse from MA API response
|
||||
- Filter chips for each type
|
||||
- Tile builders with navigation to detail screens
|
||||
|
||||
- [x] Unified Relevance-Based "All" Results
|
||||
- Scoring algorithm implemented:
|
||||
- Exact match: 100 points
|
||||
- Starts with query: 80 points
|
||||
- Contains query (word boundary): 60 points
|
||||
- Contains query (anywhere): 40 points
|
||||
- Fuzzy match: 20 points
|
||||
- Bonus for library items (albums): +10 points
|
||||
- Bonus for favorites: +5 points
|
||||
- Secondary field matching (artist name in albums/tracks)
|
||||
- Merged all results into single sorted list
|
||||
- Type indicators in subtitle (e.g., "Artist • Album")
|
||||
|
||||
- [x] Smart Cross-Referencing
|
||||
- Extracts unique artists from matched tracks/albums
|
||||
- Filters to artists whose name contains query words (min 3 chars)
|
||||
- Adds cross-referenced artists with lower relevance score (25)
|
||||
- Avoids duplicates with direct search results
|
||||
|
||||
- [x] Past Searches Row
|
||||
- Added SearchHistory table to database (schema v4)
|
||||
- Saves successful searches (deduplicated, max 10)
|
||||
- Displays as ActionChips in empty state
|
||||
- Clicking chip performs that search
|
||||
|
||||
## Remaining Ideas (Lower Priority)
|
||||
|
||||
- **Library-only toggle:** Add switch to filter search to library items only (MA API supports this)
|
||||
- **Voice search:** Add microphone button if platform supports speech recognition
|
||||
- **Search suggestions:** Typeahead from library as user types
|
||||
- **Quick actions:** Long-press result for play/queue options without navigating
|
||||
- **Clear history in settings:** Add option to clear search history
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/database/database.dart` | Added SearchHistory table, schema v4 |
|
||||
| `lib/services/database_service.dart` | Added search history methods |
|
||||
| `lib/screens/search_screen.dart` | Relevance scoring, cross-refs, history UI |
|
||||
| `lib/l10n/app_en.arb` | Added localization strings |
|
||||
@@ -59,7 +59,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.musicassistant.music_assistant"
|
||||
minSdk 21
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
android:label="Ensemble"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:appCategory="audio"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launch_background_color">#1a1a1a</color>
|
||||
</resources>
|
||||
<color name="ic_launcher_background">#1a1a1a</color>
|
||||
</resources>
|
||||
@@ -1,12 +1,15 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.10'
|
||||
ext.kotlin_version = '2.1.0'
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
classpath 'com.android.tools.build:gradle:8.7.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
@@ -14,6 +17,9 @@ buildscript {
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.1.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.9.10" apply false
|
||||
id "com.android.application" version "8.7.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 498 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 909 KiB |
@@ -0,0 +1,336 @@
|
||||
# Per-Provider Sync Implementation
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This document details the implementation of per-provider sync with source tracking, enabling instant client-side filtering that properly differentiates between multiple accounts of the same provider type (e.g., two different Spotify accounts).
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### The Core Issue
|
||||
|
||||
When a user has multiple accounts of the same provider type (e.g., two Spotify accounts: "Chris Laycock" and "Bababagoly"), the existing provider filter couldn't differentiate between them.
|
||||
|
||||
**Why?** Music Assistant's `provider_mappings` field on media items shows which providers **CAN PLAY** an item, not which provider **ADDED** it. When both Spotify accounts can play the same artist, both appear in `provider_mappings`, making client-side filtering impossible.
|
||||
|
||||
### Previous Approach (Failed)
|
||||
|
||||
The initial implementation tried to filter client-side using `provider_mappings`:
|
||||
|
||||
```dart
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
for (final mapping in mappings) {
|
||||
if (_providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Both Spotify accounts showed the same items because `provider_mappings` contained both account IDs for shared content.
|
||||
|
||||
### Race Condition Issue
|
||||
|
||||
Additionally, when rapidly toggling providers, a race condition occurred:
|
||||
1. User toggles Provider A off
|
||||
2. Sync starts for remaining providers
|
||||
3. User toggles Provider B off before sync completes
|
||||
4. First sync completes with wrong filter state
|
||||
5. UI shows incorrect results
|
||||
|
||||
## Solution: Per-Provider Sync with Source Tracking
|
||||
|
||||
### Concept
|
||||
|
||||
Instead of relying on `provider_mappings`, we:
|
||||
1. **Sync each provider separately** using the MA API's `provider` parameter
|
||||
2. **Tag each item** with which provider instance(s) returned it
|
||||
3. **Store source tracking** in the local database
|
||||
4. **Filter client-side** using the source tracking data (instant)
|
||||
5. **Background sync** keeps data fresh without blocking UI
|
||||
|
||||
### Why This Works
|
||||
|
||||
When we call the MA API with `provider: ["spotify--RKnr3jKx"]`, the server returns **only items from that specific account's library**. By syncing each provider separately and remembering which provider returned each item, we can accurately filter.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Schema Update
|
||||
|
||||
**File:** `lib/database/database.dart`
|
||||
|
||||
Added `sourceProviders` column to `LibraryCache` table:
|
||||
|
||||
```dart
|
||||
class LibraryCache extends Table {
|
||||
// ... existing columns ...
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
}
|
||||
```
|
||||
|
||||
**Schema version:** 5 → 6
|
||||
|
||||
**Migration:**
|
||||
```dart
|
||||
if (from < 6) {
|
||||
await customStatement(
|
||||
"ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Service Updates
|
||||
|
||||
**File:** `lib/services/database_service.dart`
|
||||
|
||||
Added `sourceProvider` parameter to `cacheItem()`:
|
||||
|
||||
```dart
|
||||
Future<void> cacheItem<T>({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required Map<String, dynamic> data,
|
||||
String? sourceProvider, // NEW: tracks which provider this item came from
|
||||
})
|
||||
```
|
||||
|
||||
Added new method to retrieve items with source tracking:
|
||||
|
||||
```dart
|
||||
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType)
|
||||
```
|
||||
|
||||
Returns tuples of `(itemData, sourceProviderIds)`.
|
||||
|
||||
### 3. SyncService Rewrite
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
#### New Fields
|
||||
|
||||
```dart
|
||||
// Source provider tracking for client-side filtering
|
||||
Map<String, List<String>> _albumSourceProviders = {};
|
||||
Map<String, List<String>> _artistSourceProviders = {};
|
||||
Map<String, List<String>> _audiobookSourceProviders = {};
|
||||
Map<String, List<String>> _playlistSourceProviders = {};
|
||||
```
|
||||
|
||||
#### Per-Provider Sync Logic
|
||||
|
||||
```dart
|
||||
Future<void> syncFromApi(MusicAssistantAPI api, {
|
||||
bool force = false,
|
||||
List<String>? providerInstanceIds,
|
||||
}) async {
|
||||
// If specific providers are requested, sync each separately
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
for (final providerId in providerInstanceIds) {
|
||||
// Fetch from this specific provider
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000, providerInstanceIds: [providerId]),
|
||||
api.getArtists(limit: 1000, providerInstanceIds: [providerId]),
|
||||
// ... etc
|
||||
]);
|
||||
|
||||
// Track source provider for each item
|
||||
for (final album in albums) {
|
||||
albumMap[album.itemId] = album;
|
||||
_albumSourceProviders.putIfAbsent(album.itemId, () => []);
|
||||
if (!_albumSourceProviders[album.itemId]!.contains(providerId)) {
|
||||
_albumSourceProviders[album.itemId]!.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Client-Side Filtering Methods
|
||||
|
||||
```dart
|
||||
/// Filter albums by source provider (instant, no network)
|
||||
List<Album> getAlbumsFilteredByProviders(Set<String> enabledProviderIds) {
|
||||
if (enabledProviderIds.isEmpty || _albumSourceProviders.isEmpty) {
|
||||
return _cachedAlbums;
|
||||
}
|
||||
return _cachedAlbums.where((album) {
|
||||
final sources = _albumSourceProviders[album.itemId];
|
||||
if (sources == null || sources.isEmpty) return true; // No tracking = show
|
||||
return sources.any((s) => enabledProviderIds.contains(s));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Similar methods for artists, audiobooks, playlists
|
||||
```
|
||||
|
||||
### 4. Library Screen Updates
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
#### Hybrid Provider Toggle Handler
|
||||
|
||||
```dart
|
||||
void _handleProviderToggle(String providerId, bool enabled) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
// Instant UI update via global provider toggle
|
||||
maProvider.toggleProviderEnabled(providerId, enabled);
|
||||
setState(() {}); // Rebuild with client-side filtering
|
||||
|
||||
// Background sync after 1.5s debounce
|
||||
_providerFilterDebounce?.cancel();
|
||||
_providerFilterDebounce = Timer(const Duration(milliseconds: 1500), () async {
|
||||
if (mounted) {
|
||||
await maProvider.forceLibrarySync();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Updated Tab Builders
|
||||
|
||||
Artists tab now uses source tracking:
|
||||
|
||||
```dart
|
||||
Widget _buildArtistsTab(BuildContext context, S l10n) {
|
||||
return Selector<MusicAssistantProvider, (List<Artist>, bool, Set<String>)>(
|
||||
selector: (_, provider) => (
|
||||
provider.artists,
|
||||
provider.isLoading,
|
||||
provider.enabledProviderIds.toSet()
|
||||
),
|
||||
builder: (context, data, _) {
|
||||
final (allArtists, isLoading, enabledProviders) = data;
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
final syncService = SyncService.instance;
|
||||
final filteredArtists = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getArtistsFilteredByProviders(enabledProviders)
|
||||
: allArtists;
|
||||
|
||||
// ... rest of build
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Similar updates for albums, playlists, and audiobooks tabs.
|
||||
|
||||
### 5. Provider Instance Model
|
||||
|
||||
**File:** `lib/models/provider_instance.dart` (new file)
|
||||
|
||||
```dart
|
||||
class ProviderInstance {
|
||||
final String instanceId; // e.g., "spotify--abc123"
|
||||
final String domain; // e.g., "spotify"
|
||||
final String name; // e.g., "Spotify (John's Account)"
|
||||
final bool available;
|
||||
|
||||
/// Known music provider domains
|
||||
static const Set<String> musicProviderDomains = {
|
||||
'spotify', 'tidal', 'qobuz', 'deezer', 'ytmusic', 'soundcloud',
|
||||
'apple_music', 'amazon_music', 'plex', 'subsonic', 'opensubsonic',
|
||||
'jellyfin', 'emby', 'navidrome', 'audiobookshelf', 'filesystem',
|
||||
'tunein', 'radiobrowser', 'snapcast', 'fully_kiosk',
|
||||
};
|
||||
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 1. MusicAssistantProvider.toggleProviderEnabled() │
|
||||
│ - Updates enabledProviderIds │
|
||||
│ - Triggers setState() for instant UI update │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 2. UI Rebuilds Immediately │
|
||||
│ - Selector detects enabledProviderIds change │
|
||||
│ - Calls SyncService.getArtistsFilteredByProviders() │
|
||||
│ - Filters using source tracking (instant, no network) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 3. Background Sync (after 1.5s debounce) │
|
||||
│ - forceLibrarySync() called │
|
||||
│ - Syncs each enabled provider separately │
|
||||
│ - Updates source tracking in database │
|
||||
│ - UI refreshes with fresh data │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/database/database.dart` | Added `sourceProviders` column, schema v6, migration |
|
||||
| `lib/database/database.g.dart` | Auto-generated from schema changes |
|
||||
| `lib/services/database_service.dart` | Added `sourceProvider` param, `getCachedItemsWithProviders()` |
|
||||
| `lib/services/sync_service.dart` | Per-provider sync, source tracking maps, filtering methods |
|
||||
| `lib/screens/new_library_screen.dart` | Hybrid toggle handler, updated tab builders |
|
||||
| `lib/models/provider_instance.dart` | New model for provider instances |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Fresh install: First sync should populate source tracking
|
||||
- [ ] Toggle single provider: UI updates instantly
|
||||
- [ ] Toggle multiple providers rapidly: Debounce prevents race conditions
|
||||
- [ ] Two Spotify accounts: Toggling one only shows that account's items
|
||||
- [ ] Mixed providers: Toggling Spotify doesn't affect Audiobookshelf items
|
||||
- [ ] Legacy cache: Items without source tracking show regardless of filter
|
||||
- [ ] Pull to refresh: Refreshes source tracking data
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **First sync required:** Source tracking only works after at least one sync with providers enabled
|
||||
2. **Network cost:** Per-provider sync makes N API calls (one per provider) vs. 1 call for all providers
|
||||
3. **Items in multiple accounts:** If the same item exists in both Spotify accounts, it will show when either is enabled
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Incremental sync:** Only sync changed items instead of full library
|
||||
2. **Provider-specific cache age:** Different sync intervals per provider
|
||||
3. **Offline source tracking:** Persist source tracking independently of full cache
|
||||
|
||||
## Branch
|
||||
|
||||
All changes are on the `feature/per-provider-sync` branch.
|
||||
|
||||
```bash
|
||||
git checkout feature/per-provider-sync
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
```
|
||||
feat: Add per-provider sync with source tracking for instant filtering
|
||||
|
||||
- Add sourceProviders column to database schema (v6) for tracking which
|
||||
provider instance each item came from
|
||||
- Update SyncService to sync each enabled provider separately and tag
|
||||
items with their source provider
|
||||
- Add client-side filtering methods that use source tracking to properly
|
||||
differentiate between multiple accounts of the same provider type
|
||||
- Update library UI to use instant client-side filtering with background
|
||||
sync for data freshness
|
||||
```
|
||||
@@ -0,0 +1,217 @@
|
||||
# Provider Filter Implementation Notes
|
||||
|
||||
## Overview
|
||||
|
||||
This document captures the context and learnings from an attempted implementation of provider filtering in the Ensemble app. The goal was to allow users to filter their library to show only items from a specific music provider (e.g., only one Spotify account when multiple are configured).
|
||||
|
||||
## The Problem
|
||||
|
||||
Users with multiple music provider accounts (e.g., two Spotify accounts: one personal, one family) want to filter their library view to show only items from one specific account. The Music Assistant (MA) web UI successfully implements this feature.
|
||||
|
||||
## What Works vs What Failed
|
||||
|
||||
### Works: Different Provider Types
|
||||
Filtering between different provider types (e.g., Spotify vs Tidal vs Plex) works correctly. Each provider type has distinct `provider_mappings` entries.
|
||||
|
||||
### Failed: Multiple Accounts of Same Provider
|
||||
Filtering between multiple Spotify accounts (e.g., `spotify--abc123` vs `spotify--xyz789`) does NOT work correctly. The same library items appear regardless of which Spotify account is selected.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### The provider_mappings Problem
|
||||
|
||||
MA's `provider_mappings` field on media items does NOT indicate "which account ADDED this item to the library." Instead, it indicates "which providers CAN PLAY this item."
|
||||
|
||||
Example: An artist "Ari Mason" that exists ONLY in User A's Spotify library will show provider_mappings containing BOTH Spotify accounts:
|
||||
```json
|
||||
{
|
||||
"provider_mappings": [
|
||||
{"provider_instance": "spotify--userA_id", "item_id": "..."},
|
||||
{"provider_instance": "spotify--userB_id", "item_id": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This happens because:
|
||||
1. MA's internal database stores that the artist exists
|
||||
2. MA knows both Spotify accounts CAN play this artist (Spotify has the content)
|
||||
3. MA doesn't track WHICH account originally added the item to the library
|
||||
|
||||
### MA Web UI Behavior
|
||||
|
||||
The MA web UI's provider filter works differently - it appears to use server-side filtering via the `provider` parameter on library_items API calls. When you select a single provider in MA settings, the API returns only items from that provider's library.
|
||||
|
||||
## API Investigation
|
||||
|
||||
### Server-Side Filtering Parameter
|
||||
|
||||
The MA server's `library_items` method accepts a `provider` parameter:
|
||||
- Parameter name: `provider` (string or list of strings)
|
||||
- Endpoint: `music/{type}/library_items` (e.g., `music/artists/library_items`)
|
||||
- Expected behavior: Filter to only return items from the specified provider's library
|
||||
|
||||
### What We Tried
|
||||
|
||||
1. **Client-side filtering based on provider_mappings**
|
||||
- Result: Failed - all Spotify accounts appear in mappings regardless of which added the item
|
||||
|
||||
2. **Server-side filtering with `provider` parameter**
|
||||
- Result: Partially implemented but behavior was inconsistent
|
||||
- The API accepts the parameter but may not filter correctly for same-provider-type accounts
|
||||
|
||||
3. **Using `provider_instance_id_or_domain` parameter**
|
||||
- Result: This parameter is for getting specific items by ID, not for filtering library_items
|
||||
|
||||
## Code Locations
|
||||
|
||||
### Files Modified (in stash)
|
||||
|
||||
The implementation attempt is preserved in git stash:
|
||||
```bash
|
||||
git stash list # Look for "provider-filter-and-dropdown-work"
|
||||
git stash show -p stash@{0} # View the changes
|
||||
```
|
||||
|
||||
Key files that were modified:
|
||||
|
||||
1. **lib/providers/music_assistant_provider.dart**
|
||||
- `_disabledProviders`: List of disabled provider instance IDs (local app setting)
|
||||
- `selectedProviderForFilter`: Returns single enabled provider for server-side filtering
|
||||
- `toggleProviderEnabled()`: Toggle provider on/off and trigger re-sync
|
||||
- `loadDisabledProviders()`: Load from local settings
|
||||
- `filterByProvider()`: Client-side filtering (enhanced version)
|
||||
- `isItemAllowedByProviderFilter()`: Check if item should be visible
|
||||
|
||||
2. **lib/services/music_assistant_api.dart**
|
||||
- Added `provider` parameter to: `getArtists`, `getAlbums`, `getTracks`, `getRandomArtists`, `getRandomAlbums`
|
||||
- Added `MusicProvider` model parsing from `/providers` endpoint
|
||||
- `musicProviders` getter: List of available music providers
|
||||
|
||||
3. **lib/services/sync_service.dart**
|
||||
- Added `provider` parameter to `syncFromApi()` and `forceSync()`
|
||||
- Pass provider filter to API calls during sync
|
||||
|
||||
4. **lib/services/settings_service.dart**
|
||||
- `getDisabledMusicProviders()`: Get list of disabled provider IDs
|
||||
- `setDisabledMusicProviders()`: Save disabled providers
|
||||
- `toggleProviderEnabled()`: Toggle and persist
|
||||
|
||||
5. **lib/screens/settings_screen.dart**
|
||||
- UI for toggling individual providers on/off
|
||||
- Display provider name and instance ID
|
||||
|
||||
6. **lib/screens/new_library_screen.dart**
|
||||
- Integration with provider filtering
|
||||
|
||||
7. **lib/models/music_provider.dart** (new file)
|
||||
- Model for MA provider instances
|
||||
- Fields: `instanceId`, `domain`, `name`, `available`
|
||||
|
||||
## Race Condition Issue
|
||||
|
||||
A race condition was identified where:
|
||||
1. Initial app sync starts WITHOUT provider filter
|
||||
2. User toggles provider filter
|
||||
3. `clearCache()` is called
|
||||
4. New sync with provider filter tries to start
|
||||
5. "Sync already in progress" - new sync is skipped
|
||||
6. Result: Old unfiltered data remains
|
||||
|
||||
Fix attempted: Reset `_isSyncing = false` in `clearCache()` to allow fresh sync.
|
||||
|
||||
## Potential Solutions to Explore
|
||||
|
||||
### 1. Investigate MA Server-Side Filtering
|
||||
|
||||
The MA server should support filtering by provider. Need to verify:
|
||||
- Does the `provider` parameter work for filtering between same-type providers?
|
||||
- Is there a different parameter or API endpoint?
|
||||
- Check MA source code: https://github.com/music-assistant/server
|
||||
|
||||
### 2. Library Sync Per Provider
|
||||
|
||||
Instead of syncing all items and filtering, sync each provider's library separately:
|
||||
```dart
|
||||
for (final provider in enabledProviders) {
|
||||
final items = await api.getAlbums(provider: provider.instanceId);
|
||||
// Store with provider association
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Track Item Origin
|
||||
|
||||
Store which provider originally added each item:
|
||||
- Modify database schema to track `source_provider`
|
||||
- When syncing, record which provider the item came from
|
||||
- Filter based on source, not playability
|
||||
|
||||
### 4. Use MA's Internal Library State
|
||||
|
||||
MA might have internal state about which items are in which provider's library. Investigate:
|
||||
- `/music/library/items` endpoint
|
||||
- Provider-specific library endpoints
|
||||
- MA's database schema for library associations
|
||||
|
||||
### 5. Check MA User Settings
|
||||
|
||||
The MA web UI's provider filter might be stored in user settings:
|
||||
```dart
|
||||
final userInfo = await api.getCurrentUserInfo();
|
||||
// Check for provider_filter or similar field
|
||||
```
|
||||
|
||||
## Testing Notes
|
||||
|
||||
### Log Messages to Watch
|
||||
|
||||
```
|
||||
🔌 Selected provider for filter: spotify--xxxxx
|
||||
🔍 API getArtists args: {limit: 1000, provider: spotify--xxxxx}
|
||||
🔍 API getArtists returned XXX items
|
||||
🔌 filterByProvider: XXX → YYY (disabled=[...])
|
||||
```
|
||||
|
||||
### Test Scenario
|
||||
|
||||
1. Configure two Spotify accounts in MA
|
||||
2. Add different artists to each account's library
|
||||
3. In Ensemble, select only one Spotify account
|
||||
4. Verify library shows ONLY that account's items
|
||||
5. Switch to other account, verify different items
|
||||
|
||||
### Expected vs Actual
|
||||
|
||||
- **Expected**: Artist "Ari Mason" (only in Account A) should NOT appear when Account B is selected
|
||||
- **Actual**: Artist appears regardless of which account is selected
|
||||
|
||||
## MA API Reference
|
||||
|
||||
### Get Providers
|
||||
```
|
||||
Command: providers
|
||||
Returns: List of provider instances with type, domain, instanceId, name, available
|
||||
```
|
||||
|
||||
### Library Items with Filter
|
||||
```
|
||||
Command: music/artists/library_items
|
||||
Args: {
|
||||
limit: int,
|
||||
offset: int,
|
||||
provider: string | list<string>, // Filter to specific provider(s)
|
||||
album_artists_only: bool,
|
||||
order_by: string
|
||||
}
|
||||
```
|
||||
|
||||
### Relevant MA Source Files
|
||||
|
||||
- `music_assistant/server/controllers/music.py` - Library item retrieval
|
||||
- `music_assistant/server/models/provider.py` - Provider model
|
||||
- `music_assistant/server/providers/` - Individual provider implementations
|
||||
|
||||
## Conclusion
|
||||
|
||||
The core challenge is that MA's `provider_mappings` indicates playability, not ownership. Server-side filtering via the `provider` parameter should work but needs further investigation to understand why it doesn't differentiate between multiple accounts of the same provider type.
|
||||
|
||||
The implementation is preserved in the git stash for future reference. The sorting/dropdown improvements were extracted and committed separately.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Audiobook Provider Filtering Fix
|
||||
|
||||
## Date: January 17, 2026 (Continuation)
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering issues discovered after the initial provider filtering improvements:
|
||||
1. Audiobooks were not responding to provider filter changes (browse API ignoring `providerInstanceIds`)
|
||||
2. UI not updating instantly when toggling providers (missing `notifyListeners()` call)
|
||||
3. Audiobook tabs not rebuilding on provider changes (using `context.read` instead of `Selector`)
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
After implementing capability-based provider filtering for the music library, two issues remained:
|
||||
|
||||
### Issue 1: Audiobooks Not Filtering During Sync
|
||||
|
||||
The bug report showed:
|
||||
```
|
||||
📊 Source tracking: 252 albums, 87 artists have provider info
|
||||
📚 Total audiobooks from browse: 186
|
||||
```
|
||||
|
||||
- Albums and artists had provider source tracking
|
||||
- Audiobooks had NO provider source tracking (not listed)
|
||||
- All 186 audiobooks were returned regardless of provider selection
|
||||
|
||||
### Issue 2: UI Not Updating Instantly
|
||||
|
||||
When toggling providers in the library menu:
|
||||
- Music tabs weren't updating immediately
|
||||
- Audiobooks required manual pull-to-refresh
|
||||
- Changes only appeared after ~600ms (debounce timer)
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Cause 1: Browse API Ignoring Provider Filter
|
||||
|
||||
In `lib/services/music_assistant_api.dart`, when ABS library filtering is enabled, the `getAudiobooks()` function uses the browse API but was **ignoring** the `providerInstanceIds` parameter:
|
||||
|
||||
```dart
|
||||
// BEFORE: providerInstanceIds was ignored when using browse API
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Cause 2: `notifyListeners()` Called Too Late
|
||||
|
||||
In `lib/providers/music_assistant_provider.dart`, the `toggleProviderEnabled()` function only called `notifyListeners()` **inside** the 600ms debounce timer:
|
||||
|
||||
```dart
|
||||
// BEFORE: notifyListeners only called after 600ms debounce
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
forceLibrarySync();
|
||||
notifyListeners(); // Too late! UI should update immediately
|
||||
});
|
||||
```
|
||||
|
||||
### Cause 3: Audiobook Tabs Not Listening for Changes
|
||||
|
||||
The audiobook tabs used `context.read<MusicAssistantProvider>()` which doesn't trigger rebuilds when the provider changes:
|
||||
|
||||
```dart
|
||||
// BEFORE: context.read doesn't listen for changes
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
// ... widget doesn't rebuild when enabledProviders changes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solutions
|
||||
|
||||
### Fix 1: Browse API Provider Filtering
|
||||
|
||||
**File:** `lib/services/music_assistant_api.dart` (lines 760-785)
|
||||
|
||||
Added logic to filter library paths by provider ID before browsing:
|
||||
|
||||
```dart
|
||||
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||
_logger.log('📚 getAudiobooks: enabledLibraries=$enabledLibraries, providerInstanceIds=$providerInstanceIds');
|
||||
|
||||
// If specific providers are requested, filter libraries to only matching providers
|
||||
// Library paths start with provider ID (e.g., "audiobookshelf--abc123://lb...")
|
||||
var librariesToBrowse = enabledLibraries;
|
||||
if (providerInstanceIds != null && providerInstanceIds.isNotEmpty) {
|
||||
librariesToBrowse = enabledLibraries.where((libPath) {
|
||||
// Extract provider ID from library path (everything before "://")
|
||||
final providerEnd = libPath.indexOf('://');
|
||||
if (providerEnd == -1) return false;
|
||||
final providerId = libPath.substring(0, providerEnd);
|
||||
return providerInstanceIds.contains(providerId);
|
||||
}).toList();
|
||||
|
||||
// If no libraries match the requested providers, return empty
|
||||
if (librariesToBrowse.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return await _getAudiobooksFromBrowse(librariesToBrowse, favoriteOnly: favoriteOnly);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 2: Immediate `notifyListeners()` Call
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart` (line 1149)
|
||||
|
||||
Moved `notifyListeners()` to fire immediately after `_enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
// Reload the enabled providers
|
||||
final savedEnabled = await SettingsService.getEnabledMusicProviders();
|
||||
_enabledProviderIds = savedEnabled ?? [];
|
||||
|
||||
// Notify listeners IMMEDIATELY so UI rebuilds with client-side filtering
|
||||
// This enables instant UI updates using cached data with source tracking
|
||||
notifyListeners();
|
||||
|
||||
// Cancel any pending debounce timer
|
||||
_providerFilterDebounceTimer?.cancel();
|
||||
|
||||
// Start debounced sync - waits 600ms for user to finish toggling
|
||||
// This refreshes the cache with new data from enabled providers
|
||||
_providerFilterDebounceTimer = Timer(const Duration(milliseconds: 600), () {
|
||||
_logger.log('🔄 Debounce complete, starting library sync...');
|
||||
forceLibrarySync();
|
||||
});
|
||||
```
|
||||
|
||||
### Fix 3: Audiobook Tabs Use Selector
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Wrapped `_buildBooksAuthorsTab` and `_buildAllBooksTab` in `Selector` to rebuild when `enabledProviderIds` changes:
|
||||
|
||||
```dart
|
||||
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
|
||||
// Use Selector to rebuild when enabledProviderIds changes
|
||||
return Selector<MusicAssistantProvider, Set<String>>(
|
||||
selector: (_, provider) => provider.enabledProviderIds.toSet(),
|
||||
builder: (context, enabledProviders, _) {
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
// Client-side filtering using SyncService source tracking for instant updates
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// ... rest of widget
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 4: Enhanced Sync Logging
|
||||
|
||||
**File:** `lib/services/sync_service.dart`
|
||||
|
||||
Added audiobook counts to logging:
|
||||
|
||||
```dart
|
||||
// Per-provider log (line 237)
|
||||
_logger.log(' 📥 Got ${albums.length} albums, ${artists.length} artists, ${audiobooks.length} audiobooks from $providerId');
|
||||
|
||||
// Source tracking summary (line 323)
|
||||
_logger.log('📊 Source tracking: ${_albumSourceProviders.length} albums, ${_artistSourceProviders.length} artists, ${_audiobookSourceProviders.length} audiobooks have provider info');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow After Fixes
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Toggles Provider │
|
||||
│ │
|
||||
│ 1. toggleProviderEnabled() called │
|
||||
│ 2. _enabledProviderIds updated │
|
||||
│ 3. notifyListeners() called IMMEDIATELY │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Selectors Detect Change │
|
||||
│ │
|
||||
│ Music tabs: Selector<..., (List, bool, Set<String>)> │
|
||||
│ Audiobook tabs: Selector<..., Set<String>> │
|
||||
│ │
|
||||
│ → All tabs rebuild immediately │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Client-Side Filtering (Instant) │
|
||||
│ │
|
||||
│ syncService.getAlbumsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getArtistsFilteredByProviders(enabledProviders) │
|
||||
│ syncService.getAudiobooksFilteredByProviders(enabledProviders)│
|
||||
│ │
|
||||
│ → Uses EXISTING cache with source tracking │
|
||||
│ → Instant UI update, no network required │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Background Sync (After 600ms Debounce) │
|
||||
│ │
|
||||
│ - Refreshes cache with new data from enabled providers │
|
||||
│ - Updates source tracking │
|
||||
│ - Only needed when ENABLING providers (to fetch new data) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/services/music_assistant_api.dart` | Added provider filtering to browse API path in `getAudiobooks()`, added debug logging |
|
||||
| `lib/services/sync_service.dart` | Added audiobook counts to per-provider and summary logs |
|
||||
| `lib/providers/music_assistant_provider.dart` | Moved `notifyListeners()` to fire immediately on provider toggle |
|
||||
| `lib/screens/new_library_screen.dart` | Wrapped audiobook tabs in `Selector` to rebuild on provider changes |
|
||||
|
||||
---
|
||||
|
||||
## Expected Behavior After Fixes
|
||||
|
||||
1. **Instant UI Updates:** When you toggle a provider, the library immediately updates using client-side filtering
|
||||
2. **Audiobook Source Tracking:** Audiobooks now have provider source tracking (visible in logs)
|
||||
3. **Audiobook Tab Rebuilds:** Both audiobook tabs (Authors and All Books) rebuild instantly on provider toggle
|
||||
4. **Music Tab Consistency:** Music tabs continue to work as before, now with immediate rebuilds
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Toggle a music provider, verify instant UI update (no 600ms delay)
|
||||
- [ ] Toggle between different Spotify accounts, verify correct filtering
|
||||
- [ ] Sync with ABS library filtering enabled
|
||||
- [ ] Verify audiobooks appear in source tracking log
|
||||
- [ ] Toggle ABS provider off in library menu
|
||||
- [ ] Verify audiobooks list becomes empty immediately (no pull-to-refresh needed)
|
||||
- [ ] Toggle ABS provider back on
|
||||
- [ ] Verify audiobooks reappear after sync completes
|
||||
- [ ] Test with multiple providers (Spotify audiobooks + ABS)
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/SESSION_2026_01_17_PROVIDER_FILTERING.md` - Initial provider filtering implementation
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Per-provider sync architecture
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab still doesn't filter by provider. Series are fetched as `AudiobookSeries` objects without per-provider tracking.
|
||||
|
||||
2. **Mixed Provider Scenario:** If user has both Spotify audiobooks and ABS audiobooks, and ABS library filtering is enabled, Spotify audiobooks may not be fetched because the browse API path is taken. This is a pre-existing architectural limitation.
|
||||
|
||||
3. **Enabling Providers:** When ENABLING a previously disabled provider, the UI updates immediately but shows empty until the background sync completes (because the cache doesn't have that provider's data yet). This is expected behavior.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Session 2026-01-17: Optimistic Updates & Podcast Provider Filtering
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on fixing provider filtering for podcasts, resolving iTunes podcast provider issues, and implementing optimistic updates for library buttons across detail screens.
|
||||
|
||||
## Issues Addressed
|
||||
|
||||
### 1. Podcast Provider Counts Showing Incorrectly
|
||||
|
||||
**Problem**: All Spotify accounts showed the same podcast count instead of only counting podcasts owned by each account.
|
||||
|
||||
**Root Cause**: The podcast counting logic wasn't checking the `inLibrary` field on provider mappings.
|
||||
|
||||
**Solution**:
|
||||
- Added `inLibrary` field to `ProviderMapping` model
|
||||
- Updated `getProvidersWithPodcasts()` to only count mappings where `inLibrary == true`
|
||||
- Updated podcast filtering in library screen to check `inLibrary` field
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/media_item.dart` - Added `inLibrary` field
|
||||
- `lib/providers/music_assistant_provider.dart` - Updated counting logic
|
||||
- `lib/screens/new_library_screen.dart` - Updated filtering logic
|
||||
|
||||
### 2. iTunes Podcasts Not Appearing in Library
|
||||
|
||||
**Problem**: Podcasts added from iTunes search weren't showing in library and iTunes provider filter showed 0 count.
|
||||
|
||||
**Investigation**:
|
||||
- Added `itunes_podcasts` to provider capabilities and music provider domains
|
||||
- Added synthetic provider instance creation for search-only providers
|
||||
- Added debug logging to understand podcast loading
|
||||
|
||||
**Resolution**: The issue was with Music Assistant's iTunes podcast provider itself - podcasts added via iTunes weren't being properly stored in the MA library. The user removed the iTunes provider from MA and re-added podcasts via Spotify search as a workaround.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/models/provider_instance.dart` - Added `itunes_podcasts` to capabilities
|
||||
|
||||
### 3. Episode Loading Failing for Non-Library Podcasts
|
||||
|
||||
**Problem**: When a podcast was removed from library, the old library ID became invalid and episodes wouldn't load.
|
||||
|
||||
**Solution**: Added fallback logic to try loading episodes via provider mappings when the primary ID fails.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart` - Added fallback episode loading
|
||||
|
||||
### 4. Search Filtering Rework
|
||||
|
||||
**Problem**: Server-side provider filtering was being applied to search results when it should only apply when the "in library" toggle is enabled.
|
||||
|
||||
**Solution**:
|
||||
- Removed `filterSearchResults()` calls from search methods
|
||||
- Added client-side `_filterByEnabledProviders()` helper
|
||||
- Applied filtering only when `_libraryOnly` toggle is ON
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/providers/music_assistant_provider.dart` - Removed filterSearchResults calls
|
||||
- `lib/screens/search_screen.dart` - Added client-side filtering
|
||||
|
||||
### 5. Library Buttons Not Updating Immediately
|
||||
|
||||
**Problem**: Add/remove library buttons on detail screens waited for API response before updating, causing perceived lag.
|
||||
|
||||
**Solution**: Implemented optimistic updates - update UI immediately, revert on failure.
|
||||
|
||||
**Files Modified**:
|
||||
- `lib/screens/podcast_detail_screen.dart`
|
||||
- `lib/screens/album_details_screen.dart`
|
||||
- `lib/screens/artist_details_screen.dart`
|
||||
|
||||
## Code Changes
|
||||
|
||||
### ProviderMapping.inLibrary Field
|
||||
|
||||
```dart
|
||||
// lib/models/media_item.dart
|
||||
class ProviderMapping {
|
||||
final String itemId;
|
||||
final String providerDomain;
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
final bool inLibrary; // NEW: indicates ownership
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderMapping(
|
||||
// ... other fields ...
|
||||
inLibrary: json['in_library'] == true || json['in_library'] == 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Podcast Provider Counting
|
||||
|
||||
```dart
|
||||
// lib/providers/music_assistant_provider.dart
|
||||
Map<String, int> getProvidersWithPodcasts() {
|
||||
final counts = <String, int>{};
|
||||
for (final item in _podcasts) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
// Only count if this provider "owns" the item
|
||||
if (mapping.inLibrary) {
|
||||
final instanceId = mapping.providerInstance;
|
||||
if (instanceId.isNotEmpty) {
|
||||
counts[instanceId] = (counts[instanceId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
```
|
||||
|
||||
### Optimistic Update Pattern
|
||||
|
||||
```dart
|
||||
// Example from podcast_detail_screen.dart
|
||||
Future<void> _toggleLibrary() async {
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.addedToLibrary)),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(...).catchError((e) {
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side Search Filtering
|
||||
|
||||
```dart
|
||||
// lib/screens/search_screen.dart
|
||||
Map<String, List<MediaItem>> _filterByEnabledProviders(
|
||||
Map<String, List<MediaItem>> results,
|
||||
Set<String> enabledProviders,
|
||||
) {
|
||||
if (enabledProviders.isEmpty) return results;
|
||||
|
||||
bool isItemAllowed(MediaItem item) {
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings == null || mappings.isEmpty) return true;
|
||||
return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
|
||||
}
|
||||
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowed).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
// Applied when setting results:
|
||||
if (_libraryOnly) {
|
||||
final enabledProviders = provider.enabledProviderIds.toSet();
|
||||
combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Capabilities Update
|
||||
|
||||
```dart
|
||||
// lib/models/provider_instance.dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'}, // NEW
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
// ... other providers
|
||||
};
|
||||
```
|
||||
|
||||
## Summary of All Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/media_item.dart` | Added `inLibrary` field to ProviderMapping |
|
||||
| `lib/models/provider_instance.dart` | Added `itunes_podcasts`, podcasts to capabilities |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated podcast counting, removed filterSearchResults |
|
||||
| `lib/screens/new_library_screen.dart` | Updated podcast filtering with inLibrary check |
|
||||
| `lib/screens/search_screen.dart` | Added client-side filtering for in-library mode |
|
||||
| `lib/screens/podcast_detail_screen.dart` | Optimistic updates, fallback episode loading |
|
||||
| `lib/screens/album_details_screen.dart` | Optimistic updates for library button |
|
||||
| `lib/screens/artist_details_screen.dart` | Optimistic updates for library button |
|
||||
|
||||
## Key Learnings
|
||||
|
||||
1. **inLibrary field**: Music Assistant's provider mappings include an `in_library` field that indicates whether the user added the item from that specific provider account. This is essential for accurate per-provider filtering.
|
||||
|
||||
2. **iTunes podcast provider**: The MA iTunes podcast provider is for search only - it doesn't properly store items in the library. Users should add podcasts from streaming providers like Spotify instead.
|
||||
|
||||
3. **Optimistic updates**: For better UX, library toggle buttons should update immediately and only revert if the API call fails, rather than waiting for API response.
|
||||
|
||||
4. **Search vs Library filtering**: Server-side provider filtering should only apply to library views, not search results (unless the "in library" toggle is enabled).
|
||||
@@ -0,0 +1,263 @@
|
||||
# Provider Filtering Improvements
|
||||
|
||||
## Date: January 17, 2026
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on improving the provider filtering system across the app, building on the per-provider sync implementation. Key improvements include a cleaner capability-based architecture, fixing filter visibility bugs, and extending filtering to the audiobooks library.
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Provider Capability Mapping
|
||||
|
||||
**File:** `lib/models/provider_instance.dart`
|
||||
|
||||
Replaced the clunky item-count-based filtering with a static capability map that defines what content types each provider domain supports.
|
||||
|
||||
```dart
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Self-hosted media servers (can have music and audiobooks)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks'},
|
||||
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
|
||||
// Audiobook-only providers
|
||||
'audiobookshelf': {'audiobooks'},
|
||||
|
||||
// Radio providers
|
||||
'tunein': {'radio'},
|
||||
'radiobrowser': {'radio'},
|
||||
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
```
|
||||
|
||||
**Added helper methods:**
|
||||
```dart
|
||||
Set<String> get supportedContentTypes => providerCapabilities[domain] ?? <String>{};
|
||||
bool supportsContentType(String category) => supportedContentTypes.contains(category);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Radio providers never show in Artists tab (by design, not by count)
|
||||
- Works immediately, even with empty libraries
|
||||
- Disabled providers still show in menu (if they support the content type)
|
||||
- Clean separation of concerns
|
||||
|
||||
---
|
||||
|
||||
### 2. Updated Provider Filtering in MusicAssistantProvider
|
||||
|
||||
**File:** `lib/providers/music_assistant_provider.dart`
|
||||
|
||||
Updated `getRelevantProvidersForCategory()` to filter by capability:
|
||||
|
||||
```dart
|
||||
List<(ProviderInstance, int)> getRelevantProvidersForCategory(String category) {
|
||||
// ... get counts ...
|
||||
|
||||
final result = <(ProviderInstance, int)>[];
|
||||
for (final provider in _availableMusicProviders) {
|
||||
// Only include providers that support this content type
|
||||
if (provider.supportsContentType(category)) {
|
||||
final count = counts[provider.instanceId] ?? 0;
|
||||
result.add((provider, count));
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Simplified Library Menu Provider Filter
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
Simplified the options menu overlay to show all providers that support the current category:
|
||||
|
||||
**Before (clunky):**
|
||||
```dart
|
||||
if (widget.relevantProviders.where((p) {
|
||||
final hasItems = p.$2 > 0;
|
||||
final isDisabledByUser = widget.enabledProviderIds.isNotEmpty &&
|
||||
!widget.enabledProviderIds.contains(p.$1.instanceId);
|
||||
return hasItems || isDisabledByUser;
|
||||
}).length > 1) ...[
|
||||
```
|
||||
|
||||
**After (clean):**
|
||||
```dart
|
||||
// relevantProviders is pre-filtered by capability
|
||||
if (widget.relevantProviders.length > 1) ...[
|
||||
// Show all providers that support this category
|
||||
...widget.relevantProviders.map((providerData) {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Removed Provider Toggles from Settings Screen
|
||||
|
||||
**File:** `lib/screens/settings_screen.dart`
|
||||
|
||||
Removed the "Music Providers" section entirely since provider filtering is now handled in the library menu:
|
||||
|
||||
- Removed `_providerEnabled` state variable
|
||||
- Removed provider loading code from `_loadSettings()`
|
||||
- Removed the entire Music Providers UI section (~100 lines)
|
||||
|
||||
**Rationale:** Single source of truth - provider toggles now only appear in the library menu where they're contextually relevant.
|
||||
|
||||
---
|
||||
|
||||
### 5. Fixed Audiobooks Library Provider Filtering
|
||||
|
||||
**File:** `lib/screens/new_library_screen.dart`
|
||||
|
||||
The audiobooks tabs were not filtering by provider - they loaded data once and displayed it without responding to provider changes.
|
||||
|
||||
**Fixed `_buildBooksAuthorsTab()`:**
|
||||
```dart
|
||||
final syncService = SyncService.instance;
|
||||
final enabledProviders = maProvider.enabledProviderIds.toSet();
|
||||
|
||||
// Client-side filtering using SyncService source tracking
|
||||
var audiobooks = syncService.hasSourceTracking && enabledProviders.isNotEmpty
|
||||
? syncService.getAudiobooksFilteredByProviders(enabledProviders)
|
||||
: List<Audiobook>.from(_audiobooks);
|
||||
|
||||
// Filter by favorites if enabled
|
||||
if (_showFavoritesOnly) {
|
||||
audiobooks = audiobooks.where((a) => a.favorite == true).toList();
|
||||
}
|
||||
|
||||
// Group filtered audiobooks by author (dynamic, not cached)
|
||||
final groupedByAuthor = <String, List<Audiobook>>{};
|
||||
for (final book in audiobooks) {
|
||||
final authorName = book.authorsString;
|
||||
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
|
||||
}
|
||||
```
|
||||
|
||||
**Fixed `_buildAllBooksTab()`:**
|
||||
- Same provider filtering pattern
|
||||
- Dynamic sorting after filtering
|
||||
- Updated PageStorageKey to include provider count for proper rebuild
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Provider Filter Menu Disappearing
|
||||
|
||||
**Problem:** When providers were deselected, the background sync would clear the cache and only fetch data for enabled providers. Disabled providers then had 0 items, and the menu required `> 1 providers WITH items` to show. The entire Providers section would disappear.
|
||||
|
||||
**Solution:** Changed to capability-based filtering. The menu now shows all providers that support the category, regardless of item count.
|
||||
|
||||
### Audiobooks Not Filtering by Provider
|
||||
|
||||
**Problem:** Audiobooks tabs loaded data once into `_audiobooks` and displayed it without provider filtering. Toggling providers in the menu had no effect.
|
||||
|
||||
**Solution:** Added inline provider filtering during build (same pattern as music tabs).
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `lib/models/provider_instance.dart` | Added `providerCapabilities` map and helper methods |
|
||||
| `lib/providers/music_assistant_provider.dart` | Updated `getRelevantProvidersForCategory()` to filter by capability |
|
||||
| `lib/screens/new_library_screen.dart` | Simplified menu filter logic, added audiobook provider filtering |
|
||||
| `lib/screens/settings_screen.dart` | Removed Music Providers section |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Provider Capability Map │
|
||||
│ (ProviderInstance.providerCapabilities) │
|
||||
│ │
|
||||
│ spotify → {artists, albums, tracks, playlists, audiobooks} │
|
||||
│ audiobookshelf → {audiobooks} │
|
||||
│ tunein → {radio} │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ getRelevantProvidersForCategory(category) │
|
||||
│ │
|
||||
│ - Filters _availableMusicProviders by capability │
|
||||
│ - Returns only providers that support the category │
|
||||
│ - Includes item counts for display │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Library Menu (Options) │
|
||||
│ │
|
||||
│ - Shows providers section if > 1 providers support category │
|
||||
│ - All relevant providers shown (can toggle on/off) │
|
||||
│ - Instant UI update via setState() │
|
||||
│ - Background sync after 1.5s debounce │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Tab Build Methods │
|
||||
│ │
|
||||
│ - Get enabledProviderIds from MusicAssistantProvider │
|
||||
│ - Filter data using SyncService source tracking │
|
||||
│ - Display filtered results │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Series Tab:** The Series tab in audiobooks doesn't filter by provider yet. Series are fetched separately as `AudiobookSeries` objects and don't have the same source tracking as individual audiobooks.
|
||||
|
||||
2. **First Sync Required:** Source tracking only works after at least one sync with providers enabled.
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Music tabs filter by provider correctly
|
||||
- [x] Provider menu shows only relevant providers per category
|
||||
- [x] Radio providers don't appear in Artists/Albums tabs
|
||||
- [x] Spotify appears in Audiobooks tab (has audiobooks)
|
||||
- [x] Audiobooks Authors tab filters by provider
|
||||
- [x] Audiobooks All Books tab filters by provider
|
||||
- [ ] Audiobooks Series tab filters by provider (not implemented)
|
||||
- [x] Disabled providers can be re-enabled from menu
|
||||
- [x] Settings screen no longer has provider toggles
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/PER_PROVIDER_SYNC_IMPLEMENTATION.md` - Original per-provider sync implementation
|
||||
@@ -0,0 +1,221 @@
|
||||
# Feature: Replace "Play On" Popups with Player Selector
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the `PlayerPickerSheet` popup modal with the existing Player Selector (reveal overlay) when users tap "Play on" buttons throughout the app. This provides a consistent, richer player selection experience.
|
||||
|
||||
## Current State
|
||||
|
||||
### PlayerPickerSheet Popup
|
||||
Used in 7+ places:
|
||||
- `album_details_screen.dart` - Play Album, Play From Here, Play Radio
|
||||
- `playlist_details_screen.dart` - Play Playlist, Play Radio
|
||||
- `search_screen.dart` - Play album/playlist/audiobook/radio from search
|
||||
- `podcast_detail_screen.dart` - Play Episode
|
||||
- `audiobook_detail_screen.dart` - Play Audiobook
|
||||
- `queue_screen.dart` - Transfer Queue (special case - keep popup)
|
||||
|
||||
### Player Selector (Reveal Overlay)
|
||||
- Location: `lib/widgets/player/player_reveal_overlay.dart`
|
||||
- Shows vertical list of all players with artwork, current track, sync status
|
||||
- Has hints above player list: "Long-press to sync" & "Swipe to adjust volume"
|
||||
- Triggered by pull-down gesture or device button tap
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Modify PlayerRevealOverlay
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart`
|
||||
|
||||
Add new optional parameters:
|
||||
|
||||
```dart
|
||||
class PlayerRevealOverlay extends StatefulWidget {
|
||||
final VoidCallback onDismiss;
|
||||
final double miniPlayerBottom;
|
||||
final double miniPlayerHeight;
|
||||
final bool showOnboardingHints;
|
||||
|
||||
// NEW: Callback when player is selected for a pending action
|
||||
final void Function(Player player)? onPlayerSelected;
|
||||
|
||||
// NEW: Context hint to display instead of default hints
|
||||
// e.g., "Select player to play album"
|
||||
final String? contextHint;
|
||||
|
||||
// NEW: Icon for context hint (default: speaker icon)
|
||||
final IconData? contextHintIcon;
|
||||
```
|
||||
|
||||
### 2. Modify Hint Display
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 386-394)
|
||||
|
||||
```dart
|
||||
// Current:
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
|
||||
// New:
|
||||
: widget.contextHint != null
|
||||
? buildHintRow(
|
||||
widget.contextHintIcon ?? Icons.play_circle_outline,
|
||||
widget.contextHint!,
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.holdToSync),
|
||||
const SizedBox(height: 4),
|
||||
buildHintRow(Icons.lightbulb_outline, S.of(context)!.swipeToAdjustVolume),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
### 3. Modify Player Card onTap
|
||||
|
||||
**File:** `lib/widgets/player/player_reveal_overlay.dart` (around line 437-441 and 490-494)
|
||||
|
||||
```dart
|
||||
// Current:
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(data.player);
|
||||
dismiss();
|
||||
},
|
||||
|
||||
// New:
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
if (widget.onPlayerSelected != null) {
|
||||
// Execute the pending action with selected player
|
||||
widget.onPlayerSelected!(data.player);
|
||||
} else {
|
||||
// Default behavior: just select the player
|
||||
maProvider.selectPlayer(data.player);
|
||||
}
|
||||
dismiss();
|
||||
},
|
||||
```
|
||||
|
||||
### 4. Add Helper Method to GlobalPlayerOverlay
|
||||
|
||||
**File:** `lib/widgets/global_player_overlay.dart`
|
||||
|
||||
Add a static method to show player selector with pending action:
|
||||
|
||||
```dart
|
||||
/// Show player selector for a "Play on" action
|
||||
///
|
||||
/// [contextHint] - Message shown instead of default hints
|
||||
/// [onPlayerSelected] - Called when user taps a player
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
/// contextHint: "Select player to play album",
|
||||
/// onPlayerSelected: (player) {
|
||||
/// provider.playAlbum(albumId, playerId: player.playerId);
|
||||
/// },
|
||||
/// );
|
||||
/// ```
|
||||
static void showPlayerSelectorForAction({
|
||||
required String contextHint,
|
||||
required void Function(Player player) onPlayerSelected,
|
||||
IconData? hintIcon,
|
||||
}) {
|
||||
// Implementation: trigger the player reveal overlay with these parameters
|
||||
// May need to store pending action in state and pass to overlay
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Update Calling Sites
|
||||
|
||||
Replace `showPlayerPickerSheet()` calls with new method:
|
||||
|
||||
**Example - album_details_screen.dart:**
|
||||
|
||||
```dart
|
||||
// Current (around line 1305-1316):
|
||||
showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: 'Play Album',
|
||||
onPlayerSelected: (player) async {
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
await maProvider.playAlbum(albumId, playerId: player.playerId);
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
},
|
||||
);
|
||||
|
||||
// New:
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum, // Add localization
|
||||
onPlayerSelected: (player) async {
|
||||
await maProvider.playAlbum(albumId, playerId: player.playerId);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### 6. Add Localizations
|
||||
|
||||
**File:** `lib/l10n/app_en.arb` (and other language files)
|
||||
|
||||
```json
|
||||
"selectPlayerToPlay": "Select player to play",
|
||||
"selectPlayerToPlayAlbum": "Select player to play album",
|
||||
"selectPlayerToPlayPlaylist": "Select player to play playlist",
|
||||
"selectPlayerToPlayRadio": "Select player to start radio",
|
||||
"selectPlayerToPlayEpisode": "Select player to play episode",
|
||||
"selectPlayerToPlayAudiobook": "Select player to play audiobook"
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `lib/widgets/player/player_reveal_overlay.dart` - Add parameters, modify hints & onTap
|
||||
2. `lib/widgets/global_player_overlay.dart` - Add helper method
|
||||
3. `lib/screens/album_details_screen.dart` - Replace 3 popup calls
|
||||
4. `lib/screens/playlist_details_screen.dart` - Replace 2 popup calls
|
||||
5. `lib/screens/search_screen.dart` - Replace ~5 popup calls
|
||||
6. `lib/screens/podcast_detail_screen.dart` - Replace 1 popup call
|
||||
7. `lib/screens/audiobook_detail_screen.dart` - Replace 1 popup call
|
||||
8. `lib/l10n/app_en.arb` - Add new strings
|
||||
9. `lib/l10n/app_de.arb`, `app_fr.arb`, `app_es.arb` - Translations
|
||||
|
||||
## Exception: Queue Transfer
|
||||
|
||||
**Keep the popup for `queue_screen.dart`** because:
|
||||
- Needs to filter out current player (can't transfer to self)
|
||||
- Needs to filter out certain player types
|
||||
- Different UX intent (transfer vs play)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Modify `PlayerRevealOverlay` to accept new parameters
|
||||
2. Add helper method to `GlobalPlayerOverlay`
|
||||
3. Test with one screen (album_details_screen.dart)
|
||||
4. Add localizations
|
||||
5. Migrate remaining screens
|
||||
6. Clean up unused code from PlayerPickerSheet if no longer needed elsewhere
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Album details: Play Album button
|
||||
- [ ] Album details: Play From Here (track context menu)
|
||||
- [ ] Album details: Play Radio (track context menu)
|
||||
- [ ] Playlist details: Play Playlist button
|
||||
- [ ] Playlist details: Play Radio (track context menu)
|
||||
- [ ] Search: Play album from results
|
||||
- [ ] Search: Play playlist from results
|
||||
- [ ] Search: Play radio from results
|
||||
- [ ] Podcast detail: Play episode
|
||||
- [ ] Audiobook detail: Play audiobook
|
||||
- [ ] Queue transfer still works with popup
|
||||
- [ ] Long-press to sync still works in player selector
|
||||
- [ ] Dismiss by tapping backdrop works
|
||||
- [ ] Dismiss by swiping down works
|
||||
@@ -25,4 +25,22 @@ 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_';
|
||||
|
||||
/// Podcast title hero tag prefix
|
||||
static const String podcastTitle = 'podcast_title_';
|
||||
|
||||
/// 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_';
|
||||
}
|
||||
|
||||
@@ -10,4 +10,7 @@ class NetworkConstants {
|
||||
|
||||
/// Default HTTP port
|
||||
static const int defaultHttpPort = 80;
|
||||
|
||||
/// Maximum number of pending WebSocket requests to prevent memory leaks
|
||||
static const int maxPendingRequests = 100;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ class Timings {
|
||||
/// Search debounce delay
|
||||
static const Duration searchDebounce = Duration(milliseconds: 500);
|
||||
|
||||
/// Navigation debounce to prevent double-tap navigation
|
||||
static const Duration navigationDebounce = Duration(milliseconds: 500);
|
||||
|
||||
/// Delay after track change before updating state
|
||||
static const Duration trackChangeDelay = Duration(milliseconds: 500);
|
||||
}
|
||||
|
||||
@@ -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()();
|
||||
@@ -74,6 +78,10 @@ class LibraryCache extends Table {
|
||||
/// Whether this item was deleted on the server
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Provider instance IDs that provided this item (JSON array)
|
||||
/// Used for client-side filtering by source provider
|
||||
TextColumn get sourceProviders => text().withDefault(const Constant('[]'))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {cacheKey};
|
||||
}
|
||||
@@ -139,6 +147,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()();
|
||||
@@ -153,12 +163,39 @@ class CachedQueue extends Table {
|
||||
IntColumn get position => integer()();
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue])
|
||||
/// Cached home row data for instant display on startup
|
||||
class HomeRowCache extends Table {
|
||||
/// Row type: 'recent_albums', 'discover_artists', 'discover_albums'
|
||||
TextColumn get rowType => text()();
|
||||
|
||||
/// Serialized list of items as JSON array
|
||||
TextColumn get itemsJson => text()();
|
||||
|
||||
/// When this was last updated
|
||||
DateTimeColumn get lastUpdated => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {rowType};
|
||||
}
|
||||
|
||||
/// Search history for quick access to recent searches
|
||||
class SearchHistory extends Table {
|
||||
/// Auto-incrementing ID
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
/// The search query
|
||||
TextColumn get query => text()();
|
||||
|
||||
/// When the search was performed
|
||||
DateTimeColumn get searchedAt => dateTime()();
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache, SearchHistory])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 6;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@@ -173,6 +210,30 @@ class AppDatabase extends _$AppDatabase {
|
||||
await m.createTable(cachedPlayers);
|
||||
await m.createTable(cachedQueue);
|
||||
}
|
||||
// Migration from v2 to v3: Add home row cache table
|
||||
if (from < 3) {
|
||||
await m.createTable(homeRowCache);
|
||||
}
|
||||
// Migration from v3 to v4: Add search history table
|
||||
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)');
|
||||
}
|
||||
// Migration from v5 to v6: Add source_providers column for client-side filtering
|
||||
if (from < 6) {
|
||||
await customStatement("ALTER TABLE library_cache ADD COLUMN source_providers TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -315,20 +376,57 @@ class AppDatabase extends _$AppDatabase {
|
||||
// ============================================
|
||||
|
||||
/// Cache a library item
|
||||
/// [sourceProvider] - the provider instance ID that provided this item (for filtering)
|
||||
Future<void> cacheItem({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required String data,
|
||||
String? sourceProvider,
|
||||
}) async {
|
||||
final cacheKey = '${itemType}_$itemId';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
// If sourceProvider is specified, we need to merge with existing providers
|
||||
if (sourceProvider != null) {
|
||||
final existing = await getCachedItem(itemType, itemId);
|
||||
List<String> providers = [];
|
||||
if (existing != null) {
|
||||
try {
|
||||
final existingProviders = existing.sourceProviders;
|
||||
if (existingProviders.isNotEmpty && existingProviders != '[]') {
|
||||
providers = List<String>.from(
|
||||
(await Future.value(existingProviders))
|
||||
.replaceAll('[', '')
|
||||
.replaceAll(']', '')
|
||||
.replaceAll('"', '')
|
||||
.split(',')
|
||||
.where((s) => s.trim().isNotEmpty)
|
||||
.map((s) => s.trim()),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!providers.contains(sourceProvider)) {
|
||||
providers.add(sourceProvider);
|
||||
}
|
||||
final sourceProvidersJson = '[${providers.map((p) => '"$p"').join(',')}]';
|
||||
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
sourceProviders: Value(sourceProvidersJson),
|
||||
));
|
||||
} else {
|
||||
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: data,
|
||||
lastSynced: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached items by type
|
||||
@@ -524,4 +622,139 @@ class AppDatabase extends _$AppDatabase {
|
||||
Future<void> clearAllCachedQueues() async {
|
||||
await delete(cachedQueue).go();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Home Row Cache Operations
|
||||
// ============================================
|
||||
|
||||
/// Save home row data
|
||||
Future<void> saveHomeRowCache(String rowType, String itemsJson) async {
|
||||
await into(homeRowCache).insertOnConflictUpdate(
|
||||
HomeRowCacheCompanion(
|
||||
rowType: Value(rowType),
|
||||
itemsJson: Value(itemsJson),
|
||||
lastUpdated: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get cached home row data
|
||||
Future<HomeRowCacheData?> getHomeRowCache(String rowType) async {
|
||||
return (select(homeRowCache)..where((h) => h.rowType.equals(rowType)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Clear all home row cache
|
||||
Future<void> clearHomeRowCache() async {
|
||||
await delete(homeRowCache).go();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Search History Operations
|
||||
// ============================================
|
||||
|
||||
/// Save a search query to history (deduplicates and limits to 10)
|
||||
Future<void> saveSearchQuery(String query) async {
|
||||
final trimmedQuery = query.trim();
|
||||
if (trimmedQuery.isEmpty) return;
|
||||
|
||||
await transaction(() async {
|
||||
// Delete existing entry with same query (case-insensitive)
|
||||
await (delete(searchHistory)
|
||||
..where((s) => s.query.lower().equals(trimmedQuery.toLowerCase())))
|
||||
.go();
|
||||
|
||||
// Insert the new search
|
||||
await into(searchHistory).insert(SearchHistoryCompanion.insert(
|
||||
query: trimmedQuery,
|
||||
searchedAt: DateTime.now(),
|
||||
));
|
||||
|
||||
// Keep only the 10 most recent searches
|
||||
final allSearches = await (select(searchHistory)
|
||||
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)]))
|
||||
.get();
|
||||
|
||||
if (allSearches.length > 10) {
|
||||
final toDelete = allSearches.skip(10).map((s) => s.id).toList();
|
||||
await (delete(searchHistory)..where((s) => s.id.isIn(toDelete))).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Get recent search queries (up to 10, most recent first)
|
||||
Future<List<String>> getRecentSearches() async {
|
||||
final searches = await (select(searchHistory)
|
||||
..orderBy([(s) => OrderingTerm.desc(s.searchedAt)])
|
||||
..limit(10))
|
||||
.get();
|
||||
return searches.map((s) => s.query).toList();
|
||||
}
|
||||
|
||||
/// Clear all search history
|
||||
Future<void> clearSearchHistory() async {
|
||||
await delete(searchHistory).go();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Batch Cache Operations
|
||||
// ============================================
|
||||
|
||||
/// Batch size for chunked inserts to prevent SQLite limits
|
||||
static const int _batchChunkSize = 500;
|
||||
|
||||
/// Batch cache multiple items efficiently using drift batch API
|
||||
/// This is much faster than individual inserts for large datasets.
|
||||
/// Items are chunked into batches of 500 to prevent SQLite variable limits.
|
||||
///
|
||||
/// [items] - List of items to cache, each containing:
|
||||
/// - itemType: 'album', 'artist', 'track', 'playlist', 'audiobook', etc.
|
||||
/// - itemId: the item's ID
|
||||
/// - data: JSON string of the item
|
||||
/// - sourceProviders: list of provider instance IDs
|
||||
Future<void> batchCacheItems(List<BatchCacheItem> items) async {
|
||||
if (items.isEmpty) return;
|
||||
|
||||
// Process in chunks to avoid SQLite variable limits
|
||||
for (var i = 0; i < items.length; i += _batchChunkSize) {
|
||||
final chunk = items.skip(i).take(_batchChunkSize).toList();
|
||||
|
||||
await batch((b) {
|
||||
for (final item in chunk) {
|
||||
final cacheKey = '${item.itemType}_${item.itemId}';
|
||||
final sourceProvidersJson = item.sourceProviders.isEmpty
|
||||
? '[]'
|
||||
: '[${item.sourceProviders.map((p) => '"$p"').join(',')}]';
|
||||
|
||||
b.insert(
|
||||
libraryCache,
|
||||
LibraryCacheCompanion.insert(
|
||||
cacheKey: cacheKey,
|
||||
itemType: item.itemType,
|
||||
itemId: item.itemId,
|
||||
data: item.data,
|
||||
lastSynced: DateTime.now(),
|
||||
sourceProviders: Value(sourceProvidersJson),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data class for batch cache operations
|
||||
class BatchCacheItem {
|
||||
final String itemType;
|
||||
final String itemId;
|
||||
final String data;
|
||||
final List<String> sourceProviders;
|
||||
|
||||
const BatchCacheItem({
|
||||
required this.itemType,
|
||||
required this.itemId,
|
||||
required this.data,
|
||||
this.sourceProviders = const [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -197,6 +203,7 @@
|
||||
"english": "Englisch",
|
||||
"german": "Deutsch",
|
||||
"spanish": "Spanisch",
|
||||
"french": "Französisch",
|
||||
|
||||
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
|
||||
"sortAlphabetically": "Alphabetisch sortieren",
|
||||
@@ -279,7 +286,14 @@
|
||||
"playerStateUnavailable": "Nicht verfügbar",
|
||||
"playerStateOff": "Aus",
|
||||
"playerStateIdle": "Inaktiv",
|
||||
"playerStateExternalSource": "Externe Quelle",
|
||||
"playerSelected": "Ausgewählt",
|
||||
"actionQueuedForSync": "Wird synchronisiert wenn online",
|
||||
"pendingOfflineActions": "{count} ausstehende Synchronisierung"
|
||||
"pendingOfflineActions": "{count} ausstehende Synchronisierung",
|
||||
"hintsAndTips": "Tipps & Hinweise",
|
||||
"showHints": "Hinweise anzeigen",
|
||||
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
||||
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
|
||||
"holdToSync": "Gedrückt halten zum Sync",
|
||||
"swipeToAdjustVolume": "Wischen für Lautstärke"
|
||||
}
|
||||
|
||||
@@ -251,6 +251,21 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoritePlaylists": "Favorite Playlists",
|
||||
"@favoritePlaylists": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoriteRadioStations": "Favorite Radio Stations",
|
||||
"@favoriteRadioStations": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoritePodcasts": "Favorite Podcasts",
|
||||
"@favoritePodcasts": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"searchMusic": "Search music...",
|
||||
"@searchMusic": {
|
||||
"description": "Search placeholder text"
|
||||
@@ -261,6 +276,26 @@
|
||||
"description": "Search hint text"
|
||||
},
|
||||
|
||||
"recentSearches": "Recent Searches",
|
||||
"@recentSearches": {
|
||||
"description": "Label for recent search history section"
|
||||
},
|
||||
|
||||
"clearSearchHistory": "Clear Search History",
|
||||
"@clearSearchHistory": {
|
||||
"description": "Button to clear search history"
|
||||
},
|
||||
|
||||
"searchHistoryCleared": "Search history cleared",
|
||||
"@searchHistoryCleared": {
|
||||
"description": "Confirmation message after clearing search history"
|
||||
},
|
||||
|
||||
"libraryOnly": "Library only",
|
||||
"@libraryOnly": {
|
||||
"description": "Toggle to search only in library"
|
||||
},
|
||||
|
||||
"retry": "Retry",
|
||||
"@retry": {
|
||||
"description": "Retry button label"
|
||||
@@ -296,6 +331,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"
|
||||
@@ -610,6 +675,21 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
"inLibrary": "In Library",
|
||||
"@inLibrary": {
|
||||
"description": "In library section title"
|
||||
@@ -980,6 +1060,11 @@
|
||||
"description": "Spanish language"
|
||||
},
|
||||
|
||||
"french": "French",
|
||||
"@french": {
|
||||
"description": "French language"
|
||||
},
|
||||
|
||||
"noTracksInPlaylist": "No tracks in playlist",
|
||||
"sortAlphabetically": "Sort alphabetically",
|
||||
"sortByYear": "Sort by year",
|
||||
@@ -997,10 +1082,16 @@
|
||||
"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",
|
||||
"showFavoritePodcasts": "Show a row of your favorite podcasts",
|
||||
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
|
||||
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
||||
"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",
|
||||
@@ -1011,6 +1102,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",
|
||||
@@ -1045,6 +1140,10 @@
|
||||
"audiobookLibraries": "Audiobook Libraries",
|
||||
"chooseAudiobookLibraries": "Choose which Audiobookshelf libraries to include",
|
||||
"unknownLibrary": "Unknown Library",
|
||||
"musicProviders": "Music Providers",
|
||||
"musicProvidersDescription": "Choose which accounts to show in your library",
|
||||
"libraryRefreshing": "Refreshing library...",
|
||||
"cannotDisableLastProvider": "Cannot disable the last provider",
|
||||
"noSeriesFound": "No Series Found",
|
||||
"ensembleBugReport": "Ensemble Bug Report",
|
||||
"byOwner": "By {owner}",
|
||||
@@ -1078,7 +1177,7 @@
|
||||
"description": "Hint for Last.fm API key field"
|
||||
},
|
||||
|
||||
"swipeToSwitchDevice": "Swipe to switch device",
|
||||
"swipeToSwitchDevice": "Swipe to change player",
|
||||
"@swipeToSwitchDevice": {
|
||||
"description": "Hint for device switching gesture"
|
||||
},
|
||||
@@ -1133,6 +1232,21 @@
|
||||
"description": "Singular form of audiobook"
|
||||
},
|
||||
|
||||
"albumSingular": "Album",
|
||||
"@albumSingular": {
|
||||
"description": "Singular form of album for search type indicator"
|
||||
},
|
||||
|
||||
"trackSingular": "Track",
|
||||
"@trackSingular": {
|
||||
"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",
|
||||
@@ -1178,6 +1292,16 @@
|
||||
"description": "Description for smart sort players setting"
|
||||
},
|
||||
|
||||
"disableAutoSwitch": "Disable Auto-Switch",
|
||||
"@disableAutoSwitch": {
|
||||
"description": "Setting to disable automatic player switching"
|
||||
},
|
||||
|
||||
"disableAutoSwitchDescription": "Never automatically switch to a different player",
|
||||
"@disableAutoSwitchDescription": {
|
||||
"description": "Description for disable auto-switch setting"
|
||||
},
|
||||
|
||||
"playerStateUnavailable": "Unavailable",
|
||||
"@playerStateUnavailable": {
|
||||
"description": "Player state when unavailable"
|
||||
@@ -1193,6 +1317,11 @@
|
||||
"description": "Player state when idle"
|
||||
},
|
||||
|
||||
"playerStateExternalSource": "External Source",
|
||||
"@playerStateExternalSource": {
|
||||
"description": "Player state when playing from external source (optical, Spotify, etc.)"
|
||||
},
|
||||
|
||||
"playerSelected": "Selected",
|
||||
"@playerSelected": {
|
||||
"description": "Label for selected player"
|
||||
@@ -1211,5 +1340,368 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"hintsAndTips": "Hints & Tips",
|
||||
"@hintsAndTips": {
|
||||
"description": "Settings section title for hints"
|
||||
},
|
||||
|
||||
"showHints": "Show Hints",
|
||||
"@showHints": {
|
||||
"description": "Toggle label for showing hints"
|
||||
},
|
||||
|
||||
"showHintsDescription": "Display helpful tips for discovering features",
|
||||
"@showHintsDescription": {
|
||||
"description": "Description for show hints toggle"
|
||||
},
|
||||
|
||||
"display": "Display",
|
||||
"@display": {
|
||||
"description": "Settings section title for display options"
|
||||
},
|
||||
|
||||
"showProviderIcons": "Show Provider Icons",
|
||||
"@showProviderIcons": {
|
||||
"description": "Toggle label for showing provider icons on album art"
|
||||
},
|
||||
|
||||
"showProviderIconsDescription": "Display provider logos on album art",
|
||||
"@showProviderIconsDescription": {
|
||||
"description": "Description for show provider icons toggle"
|
||||
},
|
||||
|
||||
"pullToSelectPlayers": "Pull to select players",
|
||||
"@pullToSelectPlayers": {
|
||||
"description": "Hint shown when mini player bounces"
|
||||
},
|
||||
|
||||
"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"
|
||||
},
|
||||
|
||||
"welcomeToEnsemble": "Welcome to Ensemble",
|
||||
"@welcomeToEnsemble": {
|
||||
"description": "Welcome message title for onboarding"
|
||||
},
|
||||
|
||||
"welcomeMessage": "By default your phone is the selected player.\nPull the mini player down to select another player.",
|
||||
"@welcomeMessage": {
|
||||
"description": "Welcome message body explaining how to select players"
|
||||
},
|
||||
|
||||
"skip": "Skip",
|
||||
"@skip": {
|
||||
"description": "Button to skip onboarding or hints"
|
||||
},
|
||||
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"noTracks": "No tracks",
|
||||
"@noTracks": {
|
||||
"description": "Empty state title when no tracks exist in library"
|
||||
},
|
||||
|
||||
"addTracksHint": "Add some music to your library to see tracks here",
|
||||
"@addTracksHint": {
|
||||
"description": "Hint for adding tracks to library"
|
||||
},
|
||||
|
||||
"noFavoriteRadioStations": "No favorite radio stations",
|
||||
"@noFavoriteRadioStations": {
|
||||
"description": "Empty state when no favorite radio stations"
|
||||
},
|
||||
|
||||
"longPressRadioHint": "Long-press a station and tap the heart to add it to favorites",
|
||||
"@longPressRadioHint": {
|
||||
"description": "Hint for favoriting radio stations"
|
||||
},
|
||||
|
||||
"noFavoritePodcasts": "No favorite podcasts",
|
||||
"@noFavoritePodcasts": {
|
||||
"description": "Empty state when no favorite podcasts"
|
||||
},
|
||||
|
||||
"longPressPodcastHint": "Long-press a podcast and tap the heart to add it to favorites",
|
||||
"@longPressPodcastHint": {
|
||||
"description": "Hint for favoriting podcasts"
|
||||
},
|
||||
|
||||
"failedToPlayRadioStation": "Failed to play radio station: {error}",
|
||||
"@failedToPlayRadioStation": {
|
||||
"description": "Error when playing radio station fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"itemAlreadyInLibrary": "Item is already in library",
|
||||
"@itemAlreadyInLibrary": {
|
||||
"description": "Message when trying to add item already in library"
|
||||
},
|
||||
|
||||
"failedToAddToLibrary": "Failed to add to library",
|
||||
"@failedToAddToLibrary": {
|
||||
"description": "Error when adding to library fails"
|
||||
},
|
||||
|
||||
"cannotFindLibraryId": "Cannot find library ID for removal",
|
||||
"@cannotFindLibraryId": {
|
||||
"description": "Error when library ID not found for removal"
|
||||
},
|
||||
|
||||
"failedToRemoveFromLibrary": "Failed to remove from library",
|
||||
"@failedToRemoveFromLibrary": {
|
||||
"description": "Error when removing from library fails"
|
||||
},
|
||||
|
||||
"failedToPlayAlbum": "Failed to play album: {error}",
|
||||
"@failedToPlayAlbum": {
|
||||
"description": "Error when playing album fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"playingOnPlayer": "Playing {name} on {playerName}",
|
||||
"@playingOnPlayer": {
|
||||
"description": "Status when playing item on specific player",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayPlaylist": "Failed to play playlist: {error}",
|
||||
"@failedToPlayPlaylist": {
|
||||
"description": "Error when playing playlist fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToPlayAudiobook": "Failed to play audiobook: {error}",
|
||||
"@failedToPlayAudiobook": {
|
||||
"description": "Error when playing audiobook fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"noOtherPlayersAvailable": "No other players available",
|
||||
"@noOtherPlayersAvailable": {
|
||||
"description": "Message when no other players available for transfer"
|
||||
},
|
||||
|
||||
"transferQueue": "Transfer queue",
|
||||
"@transferQueue": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"clearQueue": "Clear queue",
|
||||
"@clearQueue": {
|
||||
"description": "Tooltip for clear queue button"
|
||||
},
|
||||
|
||||
"syncingPlayer": "Syncing {playerName}...",
|
||||
"@syncingPlayer": {
|
||||
"description": "Status when syncing player",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"switchPlayer": "Switch Player",
|
||||
"@switchPlayer": {
|
||||
"description": "Label for switch player action"
|
||||
},
|
||||
|
||||
"logsCopiedToClipboard": "Logs copied to clipboard",
|
||||
"@logsCopiedToClipboard": {
|
||||
"description": "Confirmation when logs copied"
|
||||
},
|
||||
|
||||
"auto": "Auto",
|
||||
"@auto": {
|
||||
"description": "Auto option for language selection"
|
||||
},
|
||||
|
||||
"transferQueueTo": "Transfer Queue To",
|
||||
"@transferQueueTo": {
|
||||
"description": "Title for transfer queue dialog"
|
||||
},
|
||||
|
||||
"queueTransferredTo": "Queue transferred to {playerName}",
|
||||
"@queueTransferredTo": {
|
||||
"description": "Success message when queue transferred",
|
||||
"placeholders": {
|
||||
"playerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToTransferQueue": "Failed to transfer queue: {error}",
|
||||
"@failedToTransferQueue": {
|
||||
"description": "Error when queue transfer fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"failedToLoadQueue": "Failed to load queue: {error}",
|
||||
"@failedToLoadQueue": {
|
||||
"description": "Error when queue loading fails",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"transferTo": "Transfer to...",
|
||||
"@transferTo": {
|
||||
"description": "Label for transfer destination selection"
|
||||
},
|
||||
|
||||
"transferQueueToPlayer": "Transfer queue to another player",
|
||||
"@transferQueueToPlayer": {
|
||||
"description": "Tooltip for transfer queue button"
|
||||
},
|
||||
|
||||
"selectPlayerToPlay": "Select a player",
|
||||
"@selectPlayerToPlay": {
|
||||
"description": "Generic hint for selecting a player in player selector"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayAlbum": "Select player to play album",
|
||||
"@selectPlayerToPlayAlbum": {
|
||||
"description": "Hint for selecting player to play album"
|
||||
},
|
||||
|
||||
"selectPlayerToPlayPlaylist": "Select player to play playlist",
|
||||
"@selectPlayerToPlayPlaylist": {
|
||||
"description": "Hint for selecting player to play playlist"
|
||||
},
|
||||
|
||||
"selectPlayerForRadio": "Select player for radio",
|
||||
"@selectPlayerForRadio": {
|
||||
"description": "Hint for selecting player to start radio"
|
||||
},
|
||||
|
||||
"selectPlayerForEpisode": "Select player for episode",
|
||||
"@selectPlayerForEpisode": {
|
||||
"description": "Hint for selecting player to play episode"
|
||||
},
|
||||
|
||||
"selectPlayerForAudiobook": "Select player for audiobook",
|
||||
"@selectPlayerForAudiobook": {
|
||||
"description": "Hint for selecting player to play audiobook"
|
||||
},
|
||||
|
||||
"selectPlayerToAddToQueue": "Select player to add to queue",
|
||||
"@selectPlayerToAddToQueue": {
|
||||
"description": "Hint for selecting player to add to queue"
|
||||
},
|
||||
|
||||
"localConnection": "Server URL",
|
||||
"@localConnection": {
|
||||
"description": "Direct connection mode toggle label"
|
||||
},
|
||||
|
||||
"remoteConnection": "Remote Access ID",
|
||||
"@remoteConnection": {
|
||||
"description": "HA Remote Access connection mode toggle label"
|
||||
},
|
||||
|
||||
"remoteAccessId": "Remote Access ID",
|
||||
"@remoteAccessId": {
|
||||
"description": "Remote access ID field label"
|
||||
},
|
||||
|
||||
"remoteAccessIdHint": "Find this in Music Assistant Settings → Remote Access",
|
||||
"@remoteAccessIdHint": {
|
||||
"description": "Hint for remote access ID field"
|
||||
},
|
||||
|
||||
"remoteAccessNote": "For Home Assistant add-on users only",
|
||||
"@remoteAccessNote": {
|
||||
"description": "Note about remote access availability"
|
||||
},
|
||||
|
||||
"pleaseEnterRemoteId": "Please enter your Remote Access ID",
|
||||
"@pleaseEnterRemoteId": {
|
||||
"description": "Validation message for remote access ID"
|
||||
},
|
||||
|
||||
"remoteConnectionFailed": "Remote connection failed. Please check your Remote Access ID and credentials.",
|
||||
"@remoteConnectionFailed": {
|
||||
"description": "Error message when remote connection fails"
|
||||
},
|
||||
|
||||
"scanQrCode": "Scan QR Code",
|
||||
"@scanQrCode": {
|
||||
"description": "Title for QR code scanner screen"
|
||||
},
|
||||
|
||||
"pointCameraAtQr": "Point your camera at the Remote Access QR code in Music Assistant settings",
|
||||
"@pointCameraAtQr": {
|
||||
"description": "Instructions for scanning QR code"
|
||||
},
|
||||
|
||||
"cameraPermissionDenied": "Camera permission is required to scan QR codes",
|
||||
"@cameraPermissionDenied": {
|
||||
"description": "Error message when camera permission is denied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
@@ -354,6 +360,7 @@
|
||||
"english": "Inglés",
|
||||
"german": "Alemán",
|
||||
"spanish": "Español",
|
||||
"french": "Francés",
|
||||
"noTracksInPlaylist": "No hay canciones en la lista",
|
||||
"sortAlphabetically": "Ordenar alfabéticamente",
|
||||
"sortByYear": "Ordenar por año",
|
||||
@@ -434,7 +441,7 @@
|
||||
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
|
||||
"lastFmApiKey": "Clave API de Last.fm",
|
||||
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
|
||||
"swipeToSwitchDevice": "Desliza para cambiar de dispositivo",
|
||||
"swipeToSwitchDevice": "Desliza para cambiar dispositivo",
|
||||
"chapterNumber": "Capítulo {number}",
|
||||
"@chapterNumber": {
|
||||
"placeholders": {
|
||||
@@ -475,7 +482,14 @@
|
||||
"playerStateUnavailable": "No disponible",
|
||||
"playerStateOff": "Apagado",
|
||||
"playerStateIdle": "Inactivo",
|
||||
"playerStateExternalSource": "Fuente Externa",
|
||||
"playerSelected": "Seleccionado",
|
||||
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
|
||||
"pendingOfflineActions": "{count} sincronización pendiente"
|
||||
"pendingOfflineActions": "{count} sincronización pendiente",
|
||||
"hintsAndTips": "Consejos y Sugerencias",
|
||||
"showHints": "Mostrar Sugerencias",
|
||||
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
||||
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
|
||||
"holdToSync": "Mantén presionado para sync",
|
||||
"swipeToAdjustVolume": "Desliza para ajustar volumen"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -33,6 +34,10 @@ final _logger = DebugLogger();
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize SharedPreferences cache early for performance
|
||||
await SettingsService.initialize();
|
||||
_logger.log('⚡ SharedPreferences cached');
|
||||
|
||||
// Initialize local database
|
||||
await DatabaseService.instance.initialize();
|
||||
_logger.log('💾 Database initialized');
|
||||
@@ -40,6 +45,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,17 +76,42 @@ 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,
|
||||
),
|
||||
);
|
||||
|
||||
runApp(const MusicAssistantApp());
|
||||
// Set up Flutter error boundary to catch and log widget build errors
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
_logger.error('Flutter error: ${details.exceptionAsString()}', context: 'FlutterError');
|
||||
_logger.error('Stack trace:\n${details.stack}', context: 'FlutterError');
|
||||
// Still report to Flutter's default handler in debug mode
|
||||
FlutterError.presentError(details);
|
||||
};
|
||||
|
||||
// Catch errors from the platform dispatcher (platform channel errors)
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
_logger.error('Platform error: $error', context: 'PlatformDispatcher');
|
||||
_logger.error('Stack trace:\n$stack', context: 'PlatformDispatcher');
|
||||
return true; // Handled
|
||||
};
|
||||
|
||||
// Wrap app in zone to catch async errors
|
||||
runZonedGuarded(
|
||||
() => runApp(const MusicAssistantApp()),
|
||||
(error, stackTrace) {
|
||||
_logger.error('Uncaught async error: $error', context: 'ZoneError');
|
||||
_logger.error('Stack trace:\n$stackTrace', context: 'ZoneError');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class MusicAssistantApp extends StatefulWidget {
|
||||
@@ -172,7 +206,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,9 +214,18 @@ 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
|
||||
// No haptic - Android back gesture provides its own
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
GlobalPlayerOverlay.collapsePlayer(withHaptic: false);
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
@@ -225,10 +268,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);
|
||||
@@ -252,12 +302,59 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
],
|
||||
supportedLocales: S.supportedLocales,
|
||||
locale: localeProvider.locale,
|
||||
// Fallback to English when locale is not supported
|
||||
localeListResolutionCallback: (locales, supportedLocales) {
|
||||
// If user has set a specific locale, try to use it
|
||||
if (localeProvider.locale != null) {
|
||||
for (final supported in supportedLocales) {
|
||||
if (supported.languageCode == localeProvider.locale!.languageCode) {
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try to match system locales
|
||||
if (locales != null) {
|
||||
for (final locale in locales) {
|
||||
for (final supported in supportedLocales) {
|
||||
if (supported.languageCode == locale.languageCode) {
|
||||
return supported;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to English (not German)
|
||||
return const Locale('en');
|
||||
},
|
||||
themeMode: themeProvider.themeMode,
|
||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||
builder: (context, child) {
|
||||
// Wrap entire app with global player overlay
|
||||
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
||||
// Also include hidden WebView for remote WebRTC bridge
|
||||
final musicProvider = context.read<MusicAssistantProvider>();
|
||||
return Stack(
|
||||
children: [
|
||||
GlobalPlayerOverlay(child: child ?? const SizedBox.shrink()),
|
||||
// WebView for WebRTC - must be VISIBLE to avoid Android throttling
|
||||
// A tiny 4x4 pixel dot at bottom-right, nearly invisible but keeps WebView active
|
||||
Positioned(
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
child: Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.1), // Nearly invisible dot
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: musicProvider.remoteBridgeWebView,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
home: const AppStartup(),
|
||||
),
|
||||
@@ -328,8 +425,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,
|
||||
),
|
||||
);
|
||||
@@ -348,7 +445,16 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup widget that checks if user is logged in and auto-connects
|
||||
/// Startup widget that checks if user is logged in and auto-connects.
|
||||
///
|
||||
/// CRITICAL: This widget is the single source of truth for the startup transition.
|
||||
/// It loads settings FIRST (before connection), then waits for the appropriate
|
||||
/// conditions before showing HomeScreen. This eliminates the race condition between
|
||||
/// AppStartup and GlobalPlayerOverlay that caused the home screen flash.
|
||||
///
|
||||
/// For first-time users: Holds dark screen until connected + player selected,
|
||||
/// so the welcome overlay can appear seamlessly.
|
||||
/// For returning users: Transitions as soon as connection is established.
|
||||
class AppStartup extends StatefulWidget {
|
||||
const AppStartup({super.key});
|
||||
|
||||
@@ -361,9 +467,32 @@ class _AppStartupState extends State<AppStartup> {
|
||||
String? _savedServerUrl;
|
||||
bool _connectionAttempted = false;
|
||||
|
||||
// Welcome system state - loaded FIRST before connection check
|
||||
bool _settingsLoaded = false;
|
||||
bool _hasCompletedOnboarding = true; // Default to true (no welcome needed)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettingsFirst();
|
||||
}
|
||||
|
||||
/// Load settings BEFORE checking connection.
|
||||
/// This ensures we know if this is a first-time user before transitioning.
|
||||
Future<void> _loadSettingsFirst() async {
|
||||
// Load onboarding state first - this is critical for determining
|
||||
// when to transition to HomeScreen
|
||||
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_settingsLoaded = true;
|
||||
});
|
||||
|
||||
_logger.log('🚀 AppStartup: Settings loaded, hasCompletedOnboarding=$_hasCompletedOnboarding');
|
||||
|
||||
// Now check connection
|
||||
_checkAndConnect();
|
||||
}
|
||||
|
||||
@@ -388,14 +517,30 @@ class _AppStartupState extends State<AppStartup> {
|
||||
// Just wait for it to complete or timeout
|
||||
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||
|
||||
// For first-time users, we need to wait for both connection AND player selection
|
||||
// so the welcome overlay can appear immediately without home screen flash.
|
||||
// For returning users, just wait for connection.
|
||||
final needsWelcome = !_hasCompletedOnboarding;
|
||||
|
||||
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||
// Check connection state periodically
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
break;
|
||||
|
||||
if (needsWelcome) {
|
||||
// First-time user: wait for connection + player selection
|
||||
if (provider.isConnected && provider.selectedPlayer != null) {
|
||||
_logger.log('🚀 AppStartup: First-time user ready (connected + player selected)');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Returning user: just wait for connection
|
||||
if (provider.isConnected) {
|
||||
_logger.log('🚀 AppStartup: Connection established');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.connectionState == MAConnectionState.error) {
|
||||
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||
break;
|
||||
@@ -422,7 +567,8 @@ class _AppStartupState extends State<AppStartup> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Show loading while checking settings or connecting
|
||||
if (!_connectionAttempted || _isConnecting) {
|
||||
// CRITICAL: Don't transition until settings are loaded - this prevents the race
|
||||
if (!_settingsLoaded || !_connectionAttempted || _isConnecting) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1a1a1a),
|
||||
body: Center(
|
||||
|
||||
@@ -16,6 +16,10 @@ class ProviderMapping {
|
||||
final String providerInstance;
|
||||
final bool available;
|
||||
final Map<String, dynamic>? audioFormat;
|
||||
/// Whether this item is in the library for this specific provider instance.
|
||||
/// True = this provider "owns" the item (user added it from this account)
|
||||
/// False = item exists but wasn't added from this provider
|
||||
final bool inLibrary;
|
||||
|
||||
ProviderMapping({
|
||||
required this.itemId,
|
||||
@@ -23,15 +27,24 @@ class ProviderMapping {
|
||||
required this.providerInstance,
|
||||
required this.available,
|
||||
this.audioFormat,
|
||||
this.inLibrary = true,
|
||||
});
|
||||
|
||||
factory ProviderMapping.fromJson(Map<String, dynamic> json) {
|
||||
// Parse in_library field - MA uses 1/0 or true/false
|
||||
// Default to true if not present (items from library_items API are in library)
|
||||
final inLibraryValue = json['in_library'];
|
||||
final inLibrary = inLibraryValue == null
|
||||
? true // Default: items fetched from library are in library
|
||||
: inLibraryValue == true || inLibraryValue == 1;
|
||||
|
||||
return ProviderMapping(
|
||||
itemId: json['item_id'] as String? ?? '',
|
||||
providerDomain: json['provider_domain'] as String? ?? '',
|
||||
providerInstance: json['provider_instance'] as String? ?? '',
|
||||
available: json['available'] as bool? ?? true,
|
||||
audioFormat: json['audio_format'] as Map<String, dynamic>?,
|
||||
inLibrary: inLibrary,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +55,7 @@ class ProviderMapping {
|
||||
'provider_instance': providerInstance,
|
||||
'available': available,
|
||||
if (audioFormat != null) 'audio_format': audioFormat,
|
||||
'in_library': inLibrary,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -118,6 +132,17 @@ class MediaItem {
|
||||
if (duration != null) 'duration': duration!.inSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MediaItem &&
|
||||
runtimeType == other.runtimeType &&
|
||||
itemId == other.itemId &&
|
||||
provider == other.provider;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(itemId, provider);
|
||||
}
|
||||
|
||||
class Artist extends MediaItem {
|
||||
@@ -421,9 +446,20 @@ class Audiobook extends MediaItem {
|
||||
factory Audiobook.fromJson(Map<String, dynamic> json) {
|
||||
final item = MediaItem.fromJson(json);
|
||||
|
||||
// Parse year
|
||||
// Parse year - check top level first, then metadata.release_date (ABS format)
|
||||
int? year;
|
||||
final yearValue = json['year'];
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
dynamic yearValue = json['year'];
|
||||
if (yearValue == null && metadata != null) {
|
||||
// ABS stores year in metadata.release_date (can be "2024" or "2024-01-15")
|
||||
final releaseDate = metadata['release_date'];
|
||||
if (releaseDate is String && releaseDate.isNotEmpty) {
|
||||
// Extract year from date string (handles both "2024" and "2024-01-15" formats)
|
||||
yearValue = releaseDate.split('-').first;
|
||||
} else if (releaseDate is int) {
|
||||
yearValue = releaseDate;
|
||||
}
|
||||
}
|
||||
if (yearValue is int) {
|
||||
year = yearValue;
|
||||
} else if (yearValue is String) {
|
||||
@@ -453,8 +489,6 @@ class Audiobook extends MediaItem {
|
||||
|
||||
// Parse chapters - check top level first, then metadata
|
||||
List<Chapter>? chapters;
|
||||
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||
|
||||
dynamic chaptersData = json['chapters'];
|
||||
if (chaptersData == null && metadata != null) {
|
||||
chaptersData = metadata['chapters'];
|
||||
|
||||
@@ -14,6 +14,9 @@ class Player {
|
||||
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
|
||||
final List<String>? groupMembers; // List of player IDs in sync group (includes self)
|
||||
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
|
||||
final String? activeSource; // The currently active source for this player
|
||||
final bool isExternalSource; // True when an external source (optical, Spotify, etc.) is active
|
||||
final String? appId; // The app_id from MA - 'music_assistant' when MA is playing, else external source
|
||||
|
||||
Player({
|
||||
required this.playerId,
|
||||
@@ -29,6 +32,9 @@ class Player {
|
||||
this.elapsedTimeLastUpdated,
|
||||
this.groupMembers,
|
||||
this.syncedTo,
|
||||
this.activeSource,
|
||||
this.isExternalSource = false,
|
||||
this.appId,
|
||||
});
|
||||
|
||||
/// Create a copy of this Player with some fields replaced
|
||||
@@ -46,6 +52,9 @@ class Player {
|
||||
double? elapsedTimeLastUpdated,
|
||||
List<String>? groupMembers,
|
||||
String? syncedTo,
|
||||
String? activeSource,
|
||||
bool? isExternalSource,
|
||||
String? appId,
|
||||
}) {
|
||||
return Player(
|
||||
playerId: playerId ?? this.playerId,
|
||||
@@ -61,6 +70,9 @@ class Player {
|
||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
||||
groupMembers: groupMembers ?? this.groupMembers,
|
||||
syncedTo: syncedTo ?? this.syncedTo,
|
||||
activeSource: activeSource ?? this.activeSource,
|
||||
isExternalSource: isExternalSource ?? this.isExternalSource,
|
||||
appId: appId ?? this.appId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,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 {
|
||||
@@ -118,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]!;
|
||||
@@ -185,6 +215,61 @@ class Player {
|
||||
// Parse synced_to - the player ID this player is synced to
|
||||
final syncedTo = json['synced_to'] as String?;
|
||||
|
||||
// Parse active_source - the currently active source for this player
|
||||
final activeSource = json['active_source'] as String?;
|
||||
|
||||
// Detect external source (optical, Spotify, AirPlay, etc.)
|
||||
// Primary indicator: app_id - if not 'music_assistant', it's an external source
|
||||
// Secondary indicators: URI patterns and media_type
|
||||
bool isExternalSource = false;
|
||||
|
||||
// Check app_id first - this is the most reliable indicator for DLNA players
|
||||
// When MA is playing: app_id = 'music_assistant'
|
||||
// When external source: app_id = 'http', 'spotify', etc.
|
||||
final appId = json['app_id'] as String?;
|
||||
if (appId != null && appId.isNotEmpty && appId != 'music_assistant') {
|
||||
// app_id is set but not 'music_assistant' - external source is active
|
||||
isExternalSource = true;
|
||||
}
|
||||
|
||||
// Also check current_media for additional external source indicators
|
||||
if (!isExternalSource && json.containsKey('current_media')) {
|
||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||
if (currentMedia != null) {
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
|
||||
// External source indicators:
|
||||
// 1. URI is a simple identifier like 'optical', 'line_in', 'bluetooth', 'hdmi'
|
||||
// 2. URI starts with external protocols: 'spotify://', 'airplay://', etc.
|
||||
// 3. Media type is 'unknown' (MA doesn't recognize the source)
|
||||
if (uri != null) {
|
||||
final uriLower = uri.toLowerCase();
|
||||
// Simple external source identifiers (no :// or /)
|
||||
final isSimpleExternalId = !uri.contains('://') && !uri.contains('/') &&
|
||||
(uriLower == 'optical' || uriLower == 'line_in' || uriLower == 'bluetooth' ||
|
||||
uriLower == 'hdmi' || uriLower == 'tv' || uriLower == 'aux' ||
|
||||
uriLower == 'coaxial' || uriLower == 'toslink');
|
||||
// External streaming protocols
|
||||
final isExternalProtocol = uriLower.startsWith('spotify://') ||
|
||||
uriLower.startsWith('airplay://') ||
|
||||
uriLower.startsWith('cast://') ||
|
||||
uriLower.startsWith('bluetooth://');
|
||||
|
||||
isExternalSource = isSimpleExternalId || isExternalProtocol;
|
||||
}
|
||||
|
||||
// Also check media_type - 'unknown' often indicates external source
|
||||
if (!isExternalSource && mediaType == 'unknown') {
|
||||
// If media_type is unknown and URI doesn't look like MA content, it's external
|
||||
final uri = currentMedia['uri'] as String?;
|
||||
if (uri != null && !uri.startsWith('library://') && !uri.contains('://track/')) {
|
||||
isExternalSource = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Player(
|
||||
playerId: json['player_id'] as String,
|
||||
name: json['name'] as String,
|
||||
@@ -199,6 +284,9 @@ class Player {
|
||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
|
||||
groupMembers: groupMembers,
|
||||
syncedTo: syncedTo,
|
||||
activeSource: activeSource,
|
||||
isExternalSource: isExternalSource,
|
||||
appId: appId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +305,9 @@ class Player {
|
||||
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
|
||||
if (groupMembers != null) 'group_members': groupMembers,
|
||||
if (syncedTo != null) 'synced_to': syncedTo,
|
||||
if (activeSource != null) 'active_source': activeSource,
|
||||
'is_external_source': isExternalSource,
|
||||
if (appId != null) 'app_id': appId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/// Represents a music provider instance from Music Assistant.
|
||||
///
|
||||
/// Each provider (like Spotify, Tidal) can have multiple instances
|
||||
/// (e.g., two different Spotify accounts). This class represents
|
||||
/// a single provider instance with its unique ID.
|
||||
class ProviderInstance {
|
||||
/// Unique identifier for this provider instance (e.g., "spotify--abc123")
|
||||
final String instanceId;
|
||||
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this instance (e.g., "Spotify (John's Account)")
|
||||
final String name;
|
||||
|
||||
/// Whether this provider is currently available/connected
|
||||
final bool available;
|
||||
|
||||
ProviderInstance({
|
||||
required this.instanceId,
|
||||
required this.domain,
|
||||
required this.name,
|
||||
required this.available,
|
||||
});
|
||||
|
||||
/// Provider capabilities - maps domain to supported content types
|
||||
/// This determines which tabs/categories show which providers
|
||||
static const Map<String, Set<String>> providerCapabilities = {
|
||||
// Music streaming services (support artists, albums, tracks, playlists)
|
||||
// Spotify also has audiobooks and podcasts
|
||||
'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'tidal': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'qobuz': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'deezer': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'soundcloud': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
|
||||
'amazon_music': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Self-hosted media servers (can have music, audiobooks, and podcasts)
|
||||
'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
|
||||
// Music-only servers
|
||||
'subsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'opensubsonic': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'navidrome': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
'filesystem': {'artists', 'albums', 'tracks', 'playlists'},
|
||||
// Audiobook and podcast providers
|
||||
'audiobookshelf': {'audiobooks', 'podcasts'},
|
||||
'itunes_podcasts': {'podcasts'},
|
||||
// Radio providers (tunein also has podcasts)
|
||||
'tunein': {'radio', 'podcasts'},
|
||||
'radiobrowser': {'radio'},
|
||||
// Player/system providers (no library content)
|
||||
'snapcast': <String>{},
|
||||
'fully_kiosk': <String>{},
|
||||
};
|
||||
|
||||
/// Known music provider domains that provide library content
|
||||
static const Set<String> musicProviderDomains = {
|
||||
// Streaming services
|
||||
'spotify',
|
||||
'tidal',
|
||||
'qobuz',
|
||||
'deezer',
|
||||
'ytmusic',
|
||||
'soundcloud',
|
||||
'apple_music',
|
||||
'amazon_music',
|
||||
// Self-hosted / local
|
||||
'plex',
|
||||
'subsonic',
|
||||
'opensubsonic',
|
||||
'jellyfin',
|
||||
'emby',
|
||||
'navidrome',
|
||||
'audiobookshelf',
|
||||
'filesystem',
|
||||
'itunes_podcasts',
|
||||
// Other
|
||||
'tunein',
|
||||
'radiobrowser',
|
||||
'snapcast',
|
||||
'fully_kiosk',
|
||||
};
|
||||
|
||||
/// Whether this is a music provider (vs. player provider, metadata provider, etc.)
|
||||
bool get isMusicProvider => musicProviderDomains.contains(domain);
|
||||
|
||||
/// Get supported content types for this provider
|
||||
Set<String> get supportedContentTypes =>
|
||||
providerCapabilities[domain] ?? <String>{};
|
||||
|
||||
/// Check if this provider supports a specific content type/category
|
||||
bool supportsContentType(String category) =>
|
||||
supportedContentTypes.contains(category);
|
||||
|
||||
factory ProviderInstance.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderInstance(
|
||||
instanceId: json['instance_id'] as String? ?? '',
|
||||
domain: json['domain'] as String? ?? json['type'] as String? ?? '',
|
||||
name: json['name'] as String? ?? json['instance_id'] as String? ?? 'Unknown',
|
||||
available: json['available'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'instance_id': instanceId,
|
||||
'domain': domain,
|
||||
'name': name,
|
||||
'available': available,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderInstance($instanceId: $name)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProviderInstance &&
|
||||
runtimeType == other.runtimeType &&
|
||||
instanceId == other.instanceId;
|
||||
|
||||
@override
|
||||
int get hashCode => instanceId.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Represents a provider manifest from Music Assistant.
|
||||
///
|
||||
/// Contains metadata about a provider type including its icons.
|
||||
/// This is separate from ProviderInstance which represents a configured
|
||||
/// instance of a provider.
|
||||
class ProviderManifest {
|
||||
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
|
||||
final String domain;
|
||||
|
||||
/// Display name for this provider type
|
||||
final String name;
|
||||
|
||||
/// Description of the provider
|
||||
final String? description;
|
||||
|
||||
/// SVG icon as XML string
|
||||
final String? iconSvg;
|
||||
|
||||
/// SVG icon for dark theme as XML string
|
||||
final String? iconSvgDark;
|
||||
|
||||
/// Monochrome SVG icon as XML string
|
||||
final String? iconSvgMonochrome;
|
||||
|
||||
/// Material Design icon name (fallback)
|
||||
final String? icon;
|
||||
|
||||
ProviderManifest({
|
||||
required this.domain,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.iconSvg,
|
||||
this.iconSvgDark,
|
||||
this.iconSvgMonochrome,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
/// Whether this manifest has any SVG icon available
|
||||
bool get hasSvgIcon =>
|
||||
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
|
||||
|
||||
/// Get the best available SVG icon for the given brightness
|
||||
/// Prefers dark icon for dark themes, falls back to regular or monochrome
|
||||
String? getSvgIcon({bool isDark = false}) {
|
||||
if (isDark && iconSvgDark != null) {
|
||||
return iconSvgDark;
|
||||
}
|
||||
return iconSvg ?? iconSvgMonochrome;
|
||||
}
|
||||
|
||||
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
|
||||
return ProviderManifest(
|
||||
domain: json['domain'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
iconSvg: json['icon_svg'] as String?,
|
||||
iconSvgDark: json['icon_svg_dark'] as String?,
|
||||
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
|
||||
icon: json['icon'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ProviderManifest($domain: $name)';
|
||||
}
|
||||
@@ -2,168 +2,80 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/profile_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
import '../services/cache_service.dart';
|
||||
|
||||
/// Provider for managing connection state to Music Assistant server
|
||||
/// Handles WebSocket connection, authentication, and reconnection
|
||||
/// Callback types for connection events
|
||||
typedef OnConnected = Future<void> Function();
|
||||
typedef OnAuthenticated = Future<void> Function();
|
||||
typedef OnDisconnected = void Function();
|
||||
|
||||
/// Provider for connection state management.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to handle:
|
||||
/// - WebSocket connection lifecycle
|
||||
/// - Authentication flow (MA token, credentials)
|
||||
/// - Reconnection logic
|
||||
/// - Connection state broadcasting
|
||||
///
|
||||
/// Note: This is a partial extraction. Full integration requires updating
|
||||
/// all consumers to use this provider directly.
|
||||
class ConnectionProvider with ChangeNotifier {
|
||||
MusicAssistantAPI? _api;
|
||||
final AuthManager _authManager = AuthManager();
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
final CacheService _cacheService = CacheService();
|
||||
final AuthManager _authManager = AuthManager();
|
||||
|
||||
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
||||
MusicAssistantAPI? _api;
|
||||
String? _serverUrl;
|
||||
String? _error;
|
||||
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
||||
|
||||
// Callbacks for post-connection initialization
|
||||
VoidCallback? onConnected;
|
||||
VoidCallback? onAuthenticated;
|
||||
VoidCallback? onDisconnected;
|
||||
StreamSubscription? _connectionStateSubscription;
|
||||
|
||||
MAConnectionState get connectionState => _connectionState;
|
||||
String? get serverUrl => _serverUrl;
|
||||
String? get error => _error;
|
||||
bool get isConnected => _connectionState == MAConnectionState.connected ||
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
// Callbacks for connection events - set by the facade provider
|
||||
OnConnected? onConnected;
|
||||
OnAuthenticated? onAuthenticated;
|
||||
OnDisconnected? onDisconnected;
|
||||
|
||||
// User settings captured during authentication
|
||||
List<String> _providerFilter = [];
|
||||
List<String> _playerFilter = [];
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS
|
||||
// ============================================================================
|
||||
|
||||
MusicAssistantAPI? get api => _api;
|
||||
AuthManager get authManager => _authManager;
|
||||
CacheService get cacheService => _cacheService;
|
||||
String? get serverUrl => _serverUrl;
|
||||
String? get error => _error;
|
||||
MAConnectionState get connectionState => _connectionState;
|
||||
|
||||
ConnectionProvider() {
|
||||
_initialize();
|
||||
}
|
||||
bool get isConnected =>
|
||||
_connectionState == MAConnectionState.connected ||
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
|
||||
Future<void> _initialize() async {
|
||||
_serverUrl = await SettingsService.getServerUrl();
|
||||
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
|
||||
await _restoreAuthCredentials();
|
||||
await connectToServer(_serverUrl!);
|
||||
}
|
||||
}
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
|
||||
/// Restore auth credentials from persistent storage
|
||||
Future<void> _restoreAuthCredentials() async {
|
||||
final savedCredentials = await SettingsService.getAuthCredentials();
|
||||
if (savedCredentials != null) {
|
||||
_logger.log('🔐 Restoring saved auth credentials...');
|
||||
_authManager.deserializeCredentials(savedCredentials);
|
||||
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
|
||||
} else {
|
||||
_logger.log('🔐 No saved auth credentials found');
|
||||
}
|
||||
}
|
||||
// ============================================================================
|
||||
// CONNECTION
|
||||
// ============================================================================
|
||||
|
||||
/// Handle Music Assistant native authentication after WebSocket connection
|
||||
Future<bool> handleMaAuthentication() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
// First, try stored MA token
|
||||
final storedToken = await SettingsService.getMaAuthToken();
|
||||
if (storedToken != null) {
|
||||
_logger.log('🔐 Trying stored MA token...');
|
||||
final success = await _api!.authenticateWithToken(storedToken);
|
||||
if (success) {
|
||||
_logger.log('✅ MA authentication with stored token successful');
|
||||
await _fetchAndSetUserProfileName();
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
||||
await SettingsService.clearMaAuthToken();
|
||||
}
|
||||
|
||||
// No valid token - try stored credentials
|
||||
final username = await SettingsService.getUsername();
|
||||
final password = await SettingsService.getPassword();
|
||||
|
||||
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
|
||||
_logger.log('🔐 Trying stored credentials...');
|
||||
final accessToken = await _api!.loginWithCredentials(username, password);
|
||||
|
||||
if (accessToken != null) {
|
||||
_logger.log('✅ MA login with stored credentials successful');
|
||||
|
||||
final longLivedToken = await _api!.createLongLivedToken();
|
||||
if (longLivedToken != null) {
|
||||
await SettingsService.setMaAuthToken(longLivedToken);
|
||||
_logger.log('✅ Saved new long-lived MA token');
|
||||
} else {
|
||||
await SettingsService.setMaAuthToken(accessToken);
|
||||
}
|
||||
|
||||
await _fetchAndSetUserProfileName();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_logger.log('❌ MA authentication error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch user profile from MA and set owner name + create/activate local profile
|
||||
Future<void> _fetchAndSetUserProfileName() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final userInfo = await _api!.getCurrentUserInfo();
|
||||
if (userInfo == null) {
|
||||
_logger.log('⚠️ Could not fetch user profile');
|
||||
return;
|
||||
}
|
||||
|
||||
final displayName = userInfo['display_name'] as String?;
|
||||
final username = userInfo['username'] as String?;
|
||||
|
||||
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
|
||||
|
||||
final profileName = (displayName != null && displayName.isNotEmpty)
|
||||
? displayName
|
||||
: username;
|
||||
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
await SettingsService.setOwnerName(profileName);
|
||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||
|
||||
// Create/activate profile in local database
|
||||
if (DatabaseService.instance.isInitialized && username != null) {
|
||||
await ProfileService.instance.onMaAuthenticated(
|
||||
username: username,
|
||||
displayName: displayName,
|
||||
);
|
||||
_logger.log('✅ Local profile activated: $username');
|
||||
}
|
||||
} else {
|
||||
_logger.log('⚠️ No valid name in profile');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connectToServer(String serverUrl) async {
|
||||
/// Connect to the Music Assistant server
|
||||
Future<void> connect(String serverUrl) async {
|
||||
try {
|
||||
_error = null;
|
||||
_serverUrl = serverUrl;
|
||||
await SettingsService.setServerUrl(serverUrl);
|
||||
|
||||
// Disconnect existing connection
|
||||
await _api?.disconnect();
|
||||
// Dispose the old API to stop any pending reconnects
|
||||
_api?.dispose();
|
||||
|
||||
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||
|
||||
// Listen to connection state changes
|
||||
_api!.connectionState.listen(
|
||||
_connectionStateSubscription?.cancel();
|
||||
_connectionStateSubscription = _api!.connectionState.listen(
|
||||
(state) async {
|
||||
_connectionState = state;
|
||||
notifyListeners();
|
||||
@@ -173,9 +85,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
if (_api!.authRequired && !_api!.isAuthenticated) {
|
||||
_logger.log('🔐 MA auth required, attempting authentication...');
|
||||
final authenticated = await handleMaAuthentication();
|
||||
final authenticated = await _handleAuthentication();
|
||||
if (!authenticated) {
|
||||
_logger.log('❌ MA authentication failed - stopping connection flow');
|
||||
_error = 'Authentication required. Please log in again.';
|
||||
notifyListeners();
|
||||
return;
|
||||
@@ -183,15 +94,13 @@ class ConnectionProvider with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// No auth required - notify connected
|
||||
onConnected?.call();
|
||||
// No auth required, trigger callback
|
||||
await onConnected?.call();
|
||||
} else if (state == MAConnectionState.authenticated) {
|
||||
_logger.log('✅ MA authentication successful');
|
||||
onAuthenticated?.call();
|
||||
await onAuthenticated?.call();
|
||||
} else if (state == MAConnectionState.disconnected) {
|
||||
// DON'T clear caches on disconnect - keep showing cached data
|
||||
// This allows instant UI display when app resumes or reconnects
|
||||
// Caches will be refreshed when connection is restored
|
||||
_logger.log('📡 Disconnected - keeping cached data for instant resume');
|
||||
onDisconnected?.call();
|
||||
}
|
||||
},
|
||||
@@ -214,44 +123,122 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _handleAuthentication() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
final storedToken = await SettingsService.getMaAuthToken();
|
||||
if (storedToken != null) {
|
||||
_logger.log('🔐 Trying stored MA token...');
|
||||
final success = await _api!.authenticateWithToken(storedToken);
|
||||
if (success) {
|
||||
_logger.log('✅ MA authentication with stored token successful');
|
||||
await _fetchUserSettings();
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
||||
await SettingsService.clearMaAuthToken();
|
||||
}
|
||||
|
||||
final username = await SettingsService.getUsername();
|
||||
final password = await SettingsService.getPassword();
|
||||
|
||||
if (username != null &&
|
||||
password != null &&
|
||||
username.isNotEmpty &&
|
||||
password.isNotEmpty) {
|
||||
_logger.log('🔐 Trying stored credentials...');
|
||||
final accessToken = await _api!.loginWithCredentials(username, password);
|
||||
|
||||
if (accessToken != null) {
|
||||
_logger.log('✅ MA login with stored credentials successful');
|
||||
|
||||
final longLivedToken = await _api!.createLongLivedToken();
|
||||
if (longLivedToken != null) {
|
||||
await SettingsService.setMaAuthToken(longLivedToken);
|
||||
_logger.log('✅ Saved new long-lived MA token');
|
||||
} else {
|
||||
await SettingsService.setMaAuthToken(accessToken);
|
||||
}
|
||||
|
||||
await _fetchUserSettings();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_logger.log('❌ MA authentication error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchUserSettings() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final userInfo = await _api!.getCurrentUserInfo();
|
||||
if (userInfo == null) return;
|
||||
|
||||
// Set profile name
|
||||
final displayName = userInfo['display_name'] as String?;
|
||||
final username = userInfo['username'] as String?;
|
||||
final profileName =
|
||||
(displayName != null && displayName.isNotEmpty) ? displayName : username;
|
||||
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
await SettingsService.setOwnerName(profileName);
|
||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||
}
|
||||
|
||||
// Capture provider filter
|
||||
final providerFilterRaw = userInfo['provider_filter'];
|
||||
if (providerFilterRaw is List) {
|
||||
_providerFilter = providerFilterRaw.cast<String>().toList();
|
||||
if (_providerFilter.isNotEmpty) {
|
||||
_logger.log(
|
||||
'🔒 Provider filter active: ${_providerFilter.length} providers allowed');
|
||||
}
|
||||
} else {
|
||||
_providerFilter = [];
|
||||
}
|
||||
|
||||
// Capture player filter
|
||||
final playerFilterRaw = userInfo['player_filter'];
|
||||
if (playerFilterRaw is List) {
|
||||
_playerFilter = playerFilterRaw.cast<String>().toList();
|
||||
if (_playerFilter.isNotEmpty) {
|
||||
_logger.log(
|
||||
'🔒 Player filter active: ${_playerFilter.length} players allowed');
|
||||
}
|
||||
} else {
|
||||
_playerFilter = [];
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not fetch user settings (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from the server
|
||||
Future<void> disconnect() async {
|
||||
_connectionStateSubscription?.cancel();
|
||||
await _api?.disconnect();
|
||||
_connectionState = MAConnectionState.disconnected;
|
||||
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all caches and state (for logout or server change)
|
||||
void clearCachesOnLogout() {
|
||||
_cacheService.clearAll();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check connection and reconnect if needed (called when app resumes)
|
||||
/// Check connection and reconnect if needed
|
||||
Future<void> checkAndReconnect() async {
|
||||
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
|
||||
|
||||
if (_serverUrl == null) {
|
||||
_logger.log('🔄 No server URL saved, skipping reconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_connectionState != MAConnectionState.connected &&
|
||||
_connectionState != MAConnectionState.authenticated) {
|
||||
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
|
||||
try {
|
||||
await connectToServer(_serverUrl!);
|
||||
_logger.log('🔄 Reconnection successful');
|
||||
} catch (e) {
|
||||
_logger.log('🔄 Reconnection failed: $e');
|
||||
}
|
||||
} else {
|
||||
_logger.log('🔄 Already connected, connection verified');
|
||||
if (_serverUrl != null && !isConnected) {
|
||||
_logger.log('🔄 Attempting reconnection...');
|
||||
await connect(_serverUrl!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectionStateSubscription?.cancel();
|
||||
_api?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
|
||||
/// Provider for managing library data (artists, albums, tracks, playlists)
|
||||
class LibraryProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
final CacheService _cacheService;
|
||||
|
||||
MusicAssistantAPI? _api;
|
||||
|
||||
List<Artist> _artists = [];
|
||||
List<Album> _albums = [];
|
||||
List<Track> _tracks = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
// Search state persistence
|
||||
String _lastSearchQuery = '';
|
||||
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
|
||||
// Getters
|
||||
List<Artist> get artists => _artists;
|
||||
List<Album> get albums => _albums;
|
||||
List<Track> get tracks => _tracks;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
String get lastSearchQuery => _lastSearchQuery;
|
||||
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||
|
||||
LibraryProvider(this._cacheService);
|
||||
|
||||
/// Initialize with API reference after connection
|
||||
void initialize(MusicAssistantAPI api) {
|
||||
_api = api;
|
||||
}
|
||||
|
||||
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||
_lastSearchQuery = query;
|
||||
_lastSearchResults = results;
|
||||
}
|
||||
|
||||
void clearSearchState() {
|
||||
_lastSearchQuery = '';
|
||||
_lastSearchResults = {
|
||||
'artists': [],
|
||||
'albums': [],
|
||||
'tracks': [],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOME SCREEN DATA
|
||||
// ============================================================================
|
||||
|
||||
/// Get recently played albums with caching
|
||||
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached recent albums');
|
||||
return _cacheService.getCachedRecentAlbums()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh recent albums...');
|
||||
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedRecentAlbums(albums);
|
||||
return albums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch recent albums: $e');
|
||||
return _cacheService.getCachedRecentAlbums() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get discover artists (random) with caching
|
||||
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover artists');
|
||||
return _cacheService.getCachedDiscoverArtists()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover artists...');
|
||||
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverArtists(artists);
|
||||
return artists;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover artists: $e');
|
||||
return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get discover albums (random) with caching
|
||||
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover albums');
|
||||
return _cacheService.getCachedDiscoverAlbums()!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover albums...');
|
||||
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverAlbums(albums);
|
||||
return albums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover albums: $e');
|
||||
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||
void invalidateHomeCache() {
|
||||
_cacheService.invalidateHomeCache();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LIBRARY DATA
|
||||
// ============================================================================
|
||||
|
||||
Future<void> loadLibrary() async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final results = await Future.wait([
|
||||
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
|
||||
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
||||
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
||||
]);
|
||||
|
||||
_artists = results[0] as List<Artist>;
|
||||
_albums = results[1] as List<Album>;
|
||||
_tracks = results[2] as List<Track>;
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
|
||||
_error = errorInfo.userMessage;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
_artists = await _api!.getArtists(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
||||
_error = errorInfo.userMessage;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
_albums = await _api!.getAlbums(
|
||||
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||
offset: offset,
|
||||
search: search,
|
||||
artistId: artistId,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
||||
_error = errorInfo.userMessage;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DETAIL SCREEN DATA
|
||||
// ============================================================================
|
||||
|
||||
/// Get album tracks with caching
|
||||
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||
final cacheKey = '${provider}_$itemId';
|
||||
|
||||
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached album tracks for $cacheKey');
|
||||
return _cacheService.getCachedAlbumTracks(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
|
||||
final tracks = await _api!.getAlbumTracks(provider, itemId);
|
||||
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
|
||||
return tracks;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch album tracks: $e');
|
||||
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist tracks with caching
|
||||
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||
final cacheKey = '${provider}_$itemId';
|
||||
|
||||
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached playlist tracks for $cacheKey');
|
||||
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
|
||||
final tracks = await _api!.getPlaylistTracks(provider, itemId);
|
||||
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
|
||||
return tracks;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch playlist tracks: $e');
|
||||
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist albums with caching
|
||||
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
|
||||
final cacheKey = artistName.toLowerCase();
|
||||
|
||||
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached artist albums for "$artistName"');
|
||||
return _cacheService.getCachedArtistAlbums(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching albums for artist "$artistName"...');
|
||||
|
||||
final libraryAlbums = await _api!.getAlbums();
|
||||
final artistAlbums = libraryAlbums.where((album) {
|
||||
final albumArtists = album.artists;
|
||||
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
final searchResults = await _api!.search(artistName);
|
||||
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
|
||||
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
|
||||
final albumArtists = album.artists;
|
||||
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
final allAlbums = <Album>[];
|
||||
final seenNames = <String>{};
|
||||
|
||||
for (final album in [...artistAlbums, ...providerAlbums]) {
|
||||
final key = album.name.toLowerCase();
|
||||
if (!seenNames.contains(key)) {
|
||||
seenNames.add(key);
|
||||
allAlbums.add(album);
|
||||
}
|
||||
}
|
||||
|
||||
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
|
||||
return allAlbums;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch artist albums: $e');
|
||||
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate album tracks cache
|
||||
void invalidateAlbumTracksCache(String albumId) {
|
||||
_cacheService.invalidateAlbumTracksCache(albumId);
|
||||
}
|
||||
|
||||
/// Invalidate playlist tracks cache
|
||||
void invalidatePlaylistTracksCache(String playlistId) {
|
||||
_cacheService.invalidatePlaylistTracksCache(playlistId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH
|
||||
// ============================================================================
|
||||
|
||||
/// Search with caching
|
||||
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
|
||||
final cacheKey = query.toLowerCase().trim();
|
||||
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
|
||||
|
||||
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached search results for "$query"');
|
||||
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Searching for "$query"...');
|
||||
final results = await _api!.search(query);
|
||||
|
||||
final cachedResults = <String, List<MediaItem>>{
|
||||
'artists': results['artists'] ?? [],
|
||||
'albums': results['albums'] ?? [],
|
||||
'tracks': results['tracks'] ?? [],
|
||||
};
|
||||
|
||||
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||
return cachedResults;
|
||||
} catch (e) {
|
||||
_logger.log('❌ Search failed: $e');
|
||||
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
||||
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
|
||||
|
||||
try {
|
||||
return await _api!.search(query, libraryOnly: libraryOnly);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Search', e);
|
||||
return {'artists': [], 'albums': [], 'tracks': []};
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
||||
if (_api == null) return [];
|
||||
|
||||
try {
|
||||
return await _api!.getAlbumTracks(provider, itemId);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Get album tracks', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
void clearState() {
|
||||
_artists = [];
|
||||
_albums = [];
|
||||
_tracks = [];
|
||||
_isLoading = false;
|
||||
_error = null;
|
||||
clearSearchState();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ class LocaleProvider extends ChangeNotifier {
|
||||
return 'English';
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
case 'es':
|
||||
return 'Español';
|
||||
case 'fr':
|
||||
return 'Français';
|
||||
case null:
|
||||
return 'System';
|
||||
default:
|
||||
|
||||
@@ -1,964 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.dart';
|
||||
import '../services/local_player_service.dart';
|
||||
import '../services/auth/auth_manager.dart';
|
||||
import '../services/device_id_service.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../main.dart' show audioHandler;
|
||||
|
||||
/// Provider for managing player state, selection, and controls
|
||||
class PlayerProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
late final LocalPlayerService _localPlayer;
|
||||
final CacheService _cacheService;
|
||||
|
||||
// Dependencies injected from parent provider
|
||||
MusicAssistantAPI? _api;
|
||||
AuthManager? _authManager;
|
||||
String? _serverUrl;
|
||||
|
||||
// Player selection
|
||||
Player? _selectedPlayer;
|
||||
List<Player> _availablePlayers = [];
|
||||
Track? _currentTrack;
|
||||
Timer? _playerStateTimer;
|
||||
Timer? _adjacentPlayerCacheTimer;
|
||||
|
||||
// Local Playback
|
||||
bool _isLocalPlayerPowered = true;
|
||||
StreamSubscription? _localPlayerEventSubscription;
|
||||
StreamSubscription? _playerUpdatedEventSubscription;
|
||||
Timer? _localPlayerStateReportTimer;
|
||||
|
||||
// Pending metadata from player_updated events
|
||||
TrackMetadata? _pendingTrackMetadata;
|
||||
TrackMetadata? _currentNotificationMetadata;
|
||||
|
||||
// Registration guard
|
||||
Completer<void>? _registrationInProgress;
|
||||
|
||||
// Getters
|
||||
Player? get selectedPlayer => _selectedPlayer;
|
||||
List<Player> get availablePlayers => _availablePlayers;
|
||||
Track? get currentTrack => _currentTrack;
|
||||
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
|
||||
|
||||
/// Get cached track for a player (used for smooth swipe transitions)
|
||||
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
|
||||
|
||||
PlayerProvider(this._cacheService, AuthManager authManager) {
|
||||
_authManager = authManager;
|
||||
_localPlayer = LocalPlayerService(authManager);
|
||||
}
|
||||
|
||||
/// Initialize with API reference after connection
|
||||
void initialize(MusicAssistantAPI api, String serverUrl) {
|
||||
_api = api;
|
||||
_serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
/// Set up event listeners after connection
|
||||
Future<void> setupEventListeners() async {
|
||||
if (_api == null) return;
|
||||
|
||||
// Listen to built-in player events
|
||||
_localPlayerEventSubscription?.cancel();
|
||||
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
|
||||
_handleLocalPlayerEvent,
|
||||
onError: (error) {
|
||||
_logger.log('Builtin player event stream error: $error');
|
||||
},
|
||||
);
|
||||
|
||||
// Listen to player_updated events
|
||||
_playerUpdatedEventSubscription?.cancel();
|
||||
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
|
||||
_handlePlayerUpdatedEvent,
|
||||
onError: (error) {
|
||||
_logger.log('Player updated event stream error: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Initialize local playback
|
||||
Future<void> initializeLocalPlayback() async {
|
||||
await _localPlayer.initialize();
|
||||
_isLocalPlayerPowered = true;
|
||||
|
||||
// Wire up skip button callbacks
|
||||
audioHandler.onSkipToNext = () {
|
||||
_logger.log('🎵 Notification: Skip to next pressed');
|
||||
nextTrackSelectedPlayer();
|
||||
};
|
||||
audioHandler.onSkipToPrevious = () {
|
||||
_logger.log('🎵 Notification: Skip to previous pressed');
|
||||
previousTrackSelectedPlayer();
|
||||
};
|
||||
|
||||
if (_api != null) {
|
||||
await registerLocalPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to adopt an existing ghost player
|
||||
Future<bool> tryAdoptGhostPlayer() async {
|
||||
if (_api == null) return false;
|
||||
|
||||
try {
|
||||
final isFresh = await DeviceIdService.isFreshInstallation();
|
||||
if (!isFresh) {
|
||||
_logger.log('👻 Not a fresh install, skipping ghost adoption');
|
||||
return false;
|
||||
}
|
||||
|
||||
final ownerName = await SettingsService.getOwnerName();
|
||||
if (ownerName == null || ownerName.isEmpty) {
|
||||
_logger.log('👻 No owner name set, cannot adopt ghost player');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
|
||||
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
|
||||
if (adoptableId == null) {
|
||||
_logger.log('👻 No matching ghost player found - will generate new ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('👻 Found adoptable ghost: $adoptableId');
|
||||
await DeviceIdService.adoptPlayerId(adoptableId);
|
||||
|
||||
_logger.log('✅ Successfully adopted ghost player');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register local player with MA server
|
||||
Future<void> registerLocalPlayer() async {
|
||||
if (_api == null) return;
|
||||
|
||||
if (_registrationInProgress != null) {
|
||||
_logger.log('⏳ Registration already in progress, waiting...');
|
||||
return _registrationInProgress!.future;
|
||||
}
|
||||
|
||||
_registrationInProgress = Completer<void>();
|
||||
|
||||
try {
|
||||
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
_logger.log('🆔 Using player ID: $playerId');
|
||||
|
||||
await SettingsService.setBuiltinPlayerId(playerId);
|
||||
final name = await SettingsService.getLocalPlayerName();
|
||||
|
||||
final existingPlayers = await _api!.getPlayers();
|
||||
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||
|
||||
if (existingPlayer != null && existingPlayer.available) {
|
||||
_logger.log('✅ Player already registered and available: $playerId');
|
||||
_startReportingLocalPlayerState();
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.complete();
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
return;
|
||||
} else if (existingPlayer != null && !existingPlayer.available) {
|
||||
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
|
||||
} else {
|
||||
_logger.log('🆔 Player not found in MA, registering as new');
|
||||
}
|
||||
|
||||
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
|
||||
await _api!.registerBuiltinPlayer(playerId, name);
|
||||
|
||||
_logger.log('✅ Player registration complete');
|
||||
_startReportingLocalPlayerState();
|
||||
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.complete();
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
} catch (e) {
|
||||
_logger.log('❌ CRITICAL: Player registration failed: $e');
|
||||
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||
_registrationInProgress!.completeError(e);
|
||||
}
|
||||
_registrationInProgress = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _startReportingLocalPlayerState() {
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
|
||||
try {
|
||||
await _reportLocalPlayerState();
|
||||
} catch (e) {
|
||||
_logger.log('Error reporting local player state (will retry): $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reportLocalPlayerState() async {
|
||||
if (_api == null) return;
|
||||
|
||||
// Don't try to report state if not authenticated - avoids spamming errors
|
||||
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
|
||||
|
||||
final playerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (playerId == null) return;
|
||||
|
||||
final isPlaying = _localPlayer.isPlaying;
|
||||
final position = _localPlayer.position.inSeconds;
|
||||
final volume = (_localPlayer.volume * 100).round();
|
||||
final isPaused = !isPlaying && position > 0;
|
||||
|
||||
await _api!.updateBuiltinPlayerState(
|
||||
playerId,
|
||||
powered: _isLocalPlayerPowered,
|
||||
playing: isPlaying,
|
||||
paused: isPaused,
|
||||
position: position,
|
||||
volume: volume,
|
||||
muted: _localPlayer.volume == 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
|
||||
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
|
||||
|
||||
try {
|
||||
final eventPlayerId = event['player_id'] as String?;
|
||||
final myPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
|
||||
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
|
||||
return;
|
||||
}
|
||||
|
||||
final command = (event['type'] as String?) ?? (event['command'] as String?);
|
||||
|
||||
switch (command) {
|
||||
case 'play_media':
|
||||
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
|
||||
|
||||
if (urlPath != null && _serverUrl != null) {
|
||||
String fullUrl;
|
||||
if (urlPath.startsWith('http')) {
|
||||
fullUrl = urlPath;
|
||||
} else {
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
||||
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
|
||||
fullUrl = '$baseUrl$path';
|
||||
}
|
||||
|
||||
TrackMetadata metadata;
|
||||
if (_pendingTrackMetadata != null) {
|
||||
metadata = _pendingTrackMetadata!;
|
||||
} else {
|
||||
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
|
||||
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
|
||||
final albumName = event['album_name'] as String? ?? event['album'] as String?;
|
||||
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
|
||||
final durationSecs = event['duration'] as int?;
|
||||
|
||||
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
|
||||
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
|
||||
}
|
||||
|
||||
metadata = TrackMetadata(
|
||||
title: trackName,
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
artworkUrl: artworkUrl,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
);
|
||||
}
|
||||
|
||||
_localPlayer.setCurrentTrackMetadata(metadata);
|
||||
_currentNotificationMetadata = metadata;
|
||||
await _localPlayer.playUrl(fullUrl);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
await _localPlayer.stop();
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
await _localPlayer.pause();
|
||||
break;
|
||||
|
||||
case 'play':
|
||||
await _localPlayer.play();
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
final position = event['position'] as int?;
|
||||
if (position != null) {
|
||||
await _localPlayer.seek(Duration(seconds: position));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volume_set':
|
||||
final volume = event['volume_level'] as int?;
|
||||
if (volume != null) {
|
||||
await _localPlayer.setVolume(volume / 100.0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'power_on':
|
||||
case 'power_off':
|
||||
case 'power':
|
||||
bool? newPowerState;
|
||||
if (command == 'power_on') {
|
||||
newPowerState = true;
|
||||
} else if (command == 'power_off') {
|
||||
newPowerState = false;
|
||||
} else {
|
||||
newPowerState = event['powered'] as bool?;
|
||||
}
|
||||
|
||||
if (newPowerState != null) {
|
||||
_isLocalPlayerPowered = newPowerState;
|
||||
if (!_isLocalPlayerPowered) {
|
||||
await _localPlayer.stop();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
await _reportLocalPlayerState();
|
||||
} catch (e) {
|
||||
_logger.log('Error handling local player event: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
|
||||
try {
|
||||
final playerId = event['player_id'] as String?;
|
||||
if (playerId == null) return;
|
||||
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||
_updatePlayerState();
|
||||
}
|
||||
|
||||
final currentMedia = event['current_media'] as Map<String, dynamic>?;
|
||||
final playerName = event['name'] as String? ?? playerId;
|
||||
|
||||
if (currentMedia != null) {
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
if (mediaType != 'flow_stream') {
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
final albumName = currentMedia['album'] as String?;
|
||||
final imageUrl = currentMedia['image_url'] as String?;
|
||||
|
||||
Map<String, dynamic>? metadata;
|
||||
if (imageUrl != null && _serverUrl != null) {
|
||||
var finalImageUrl = imageUrl;
|
||||
try {
|
||||
final imgUri = Uri.parse(imageUrl);
|
||||
final queryString = imgUri.query;
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||
}
|
||||
finalImageUrl = '$baseUrl/imageproxy?$queryString';
|
||||
} catch (e) {
|
||||
// Use original URL
|
||||
}
|
||||
metadata = {
|
||||
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
|
||||
};
|
||||
}
|
||||
|
||||
final trackFromEvent = Track(
|
||||
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
||||
provider: 'library',
|
||||
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
||||
uri: currentMedia['uri'] as String?,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
|
||||
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||
_currentTrack = trackFromEvent;
|
||||
}
|
||||
|
||||
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
|
||||
|
||||
if (imageUrl != null) {
|
||||
final images = metadata?['images'] as List?;
|
||||
if (images != null && images.isNotEmpty) {
|
||||
final imagePath = images[0]['path'] as String?;
|
||||
if (imagePath != null) {
|
||||
_precacheImage(imagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle notification metadata for local player
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
|
||||
|
||||
if (currentMedia == null) return;
|
||||
|
||||
final title = currentMedia['title'] as String? ?? 'Unknown Track';
|
||||
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
|
||||
final album = currentMedia['album'] as String?;
|
||||
var imageUrl = currentMedia['image_url'] as String?;
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
|
||||
if (imageUrl != null && _serverUrl != null) {
|
||||
try {
|
||||
final imgUri = Uri.parse(imageUrl);
|
||||
final queryString = imgUri.query;
|
||||
var baseUrl = _serverUrl!;
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||
}
|
||||
imageUrl = '$baseUrl/imageproxy?$queryString';
|
||||
} catch (e) {
|
||||
if (imageUrl != null && imageUrl.startsWith('http://')) {
|
||||
imageUrl = imageUrl.replaceFirst('http://', 'https://');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final newMetadata = TrackMetadata(
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
artworkUrl: imageUrl,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
);
|
||||
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
if (mediaType == 'flow_stream') return;
|
||||
|
||||
_pendingTrackMetadata = newMetadata;
|
||||
|
||||
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
|
||||
(_currentNotificationMetadata!.title != title ||
|
||||
_currentNotificationMetadata!.artist != artist);
|
||||
|
||||
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
|
||||
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
|
||||
_currentNotificationMetadata = newMetadata;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error handling player_updated event: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER SELECTION
|
||||
// ============================================================================
|
||||
|
||||
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final allPlayers = await _api!.getPlayers();
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
|
||||
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
||||
|
||||
int filteredCount = 0;
|
||||
|
||||
_availablePlayers = allPlayers.where((player) {
|
||||
final nameLower = player.name.toLowerCase();
|
||||
|
||||
if (nameLower.contains('music assistant mobile')) {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
|
||||
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
|
||||
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Also filter "This Device" named players without proper provider
|
||||
if (nameLower == 'this device') {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.playerId.startsWith('ensemble_')) {
|
||||
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!player.available) {
|
||||
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
||||
return true;
|
||||
}
|
||||
filteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
_cacheService.updatePlayersLastFetched();
|
||||
|
||||
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
|
||||
|
||||
if (_availablePlayers.isNotEmpty) {
|
||||
Player? playerToSelect;
|
||||
|
||||
if (_selectedPlayer != null) {
|
||||
final stillAvailable = _availablePlayers.any(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
||||
);
|
||||
if (stillAvailable) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||
|
||||
if (lastSelectedPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == lastSelectedPlayerId && p.available,
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (playerToSelect == null && builtinPlayerId != null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == builtinPlayerId && p.available,
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
try {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.state == 'playing' && p.available,
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (playerToSelect == null) {
|
||||
playerToSelect = _availablePlayers.firstWhere(
|
||||
(p) => p.available,
|
||||
orElse: () => _availablePlayers.first,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
selectPlayer(playerToSelect);
|
||||
}
|
||||
|
||||
_preloadAdjacentPlayers(preloadAll: true);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Load and select players', e);
|
||||
}
|
||||
}
|
||||
|
||||
void selectPlayer(Player player, {bool skipNotify = false}) {
|
||||
_selectedPlayer = player;
|
||||
SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||
_startPlayerStatePolling();
|
||||
_preloadAdjacentPlayers();
|
||||
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
|
||||
if (_api == null) return;
|
||||
|
||||
final players = _availablePlayers.where((p) => p.available).toList();
|
||||
if (players.isEmpty) return;
|
||||
|
||||
if (preloadAll) {
|
||||
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
|
||||
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||
_logger.log('🖼️ Preloading complete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedPlayer == null || players.length <= 1) return;
|
||||
|
||||
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
|
||||
if (currentIndex == -1) return;
|
||||
|
||||
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
||||
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
||||
|
||||
final playersToPreload = <Player>{};
|
||||
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
|
||||
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
|
||||
|
||||
for (final player in playersToPreload) {
|
||||
_preloadPlayerTrack(player);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _preloadPlayerTrack(Player player) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
if (!player.available || !player.powered) {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||
return;
|
||||
}
|
||||
|
||||
final queue = await _api!.getQueue(player.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
final track = queue.currentItem!.track;
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||
final newHasImage = track.metadata?['images'] != null;
|
||||
|
||||
if (!existingHasImage || newHasImage) {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
||||
}
|
||||
|
||||
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
|
||||
if (artworkUrl512 != null) {
|
||||
await _precacheImage(artworkUrl512);
|
||||
}
|
||||
} else {
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||
if (existingTrack?.metadata?['images'] == null) {
|
||||
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error preloading player track for ${player.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _precacheImage(String url) async {
|
||||
try {
|
||||
final imageProvider = NetworkImage(url);
|
||||
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||
final completer = Completer<void>();
|
||||
late ImageStreamListener listener;
|
||||
|
||||
listener = ImageStreamListener(
|
||||
(ImageInfo info, bool synchronousCall) {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
imageStream.removeListener(listener);
|
||||
},
|
||||
onError: (exception, stackTrace) {
|
||||
if (!completer.isCompleted) completer.completeError(exception);
|
||||
imageStream.removeListener(listener);
|
||||
},
|
||||
);
|
||||
|
||||
imageStream.addListener(listener);
|
||||
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||
imageStream.removeListener(listener);
|
||||
});
|
||||
} catch (e) {
|
||||
_logger.log('Image precache failed for $url: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> preloadAllPlayerTracks() async {
|
||||
if (_api == null) return;
|
||||
final players = _availablePlayers.where((p) => p.available).toList();
|
||||
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||
}
|
||||
|
||||
void _startPlayerStatePolling() {
|
||||
_playerStateTimer?.cancel();
|
||||
if (_selectedPlayer == null) return;
|
||||
|
||||
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
||||
try {
|
||||
await _updatePlayerState();
|
||||
} catch (e) {
|
||||
_logger.log('Error updating player state (will retry): $e');
|
||||
}
|
||||
});
|
||||
|
||||
_updatePlayerState();
|
||||
}
|
||||
|
||||
Future<void> _updatePlayerState() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
try {
|
||||
bool stateChanged = false;
|
||||
|
||||
final allPlayers = await _api!.getPlayers();
|
||||
final updatedPlayer = allPlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
orElse: () => _selectedPlayer!,
|
||||
);
|
||||
|
||||
_selectedPlayer = updatedPlayer;
|
||||
stateChanged = true;
|
||||
|
||||
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
|
||||
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
|
||||
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
|
||||
|
||||
if (!shouldShowTrack) {
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
if (stateChanged) notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
if (_currentTrack == null ||
|
||||
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||
_currentTrack!.name != queue.currentItem!.track.name) {
|
||||
_currentTrack = queue.currentItem!.track;
|
||||
stateChanged = true;
|
||||
|
||||
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
|
||||
final track = _currentTrack!;
|
||||
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||
_localPlayer.updateNotification(
|
||||
id: track.uri ?? track.itemId,
|
||||
title: track.name,
|
||||
artist: track.artistsString,
|
||||
album: track.album?.name,
|
||||
artworkUrl: artworkUrl,
|
||||
duration: track.duration,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (_currentTrack != null) {
|
||||
_currentTrack = null;
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged) notifyListeners();
|
||||
} catch (e) {
|
||||
_logger.log('❌ Error updating player state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYER CONTROLS
|
||||
// ============================================================================
|
||||
|
||||
Future<void> playPauseSelectedPlayer() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
|
||||
if (_selectedPlayer!.isPlaying) {
|
||||
await _api!.pausePlayer(_selectedPlayer!.playerId);
|
||||
} else {
|
||||
await _api!.resumePlayer(_selectedPlayer!.playerId);
|
||||
}
|
||||
|
||||
await refreshPlayers();
|
||||
}
|
||||
|
||||
Future<void> nextTrackSelectedPlayer() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
await _api!.nextTrack(_selectedPlayer!.playerId);
|
||||
await Future.delayed(Timings.trackChangeDelay);
|
||||
await _updatePlayerState();
|
||||
}
|
||||
|
||||
Future<void> previousTrackSelectedPlayer() async {
|
||||
if (_selectedPlayer == null || _api == null) return;
|
||||
await _api!.previousTrack(_selectedPlayer!.playerId);
|
||||
await Future.delayed(Timings.trackChangeDelay);
|
||||
await _updatePlayerState();
|
||||
}
|
||||
|
||||
Future<void> refreshPlayers() async {
|
||||
final previousState = _selectedPlayer?.state;
|
||||
final previousVolume = _selectedPlayer?.volumeLevel;
|
||||
|
||||
await loadAndSelectPlayers(forceRefresh: true);
|
||||
|
||||
bool stateChanged = false;
|
||||
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
||||
try {
|
||||
final updatedPlayer = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||
);
|
||||
|
||||
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
_selectedPlayer = updatedPlayer;
|
||||
} catch (e) {
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged) notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> togglePower(String playerId) async {
|
||||
if (_api == null) return;
|
||||
|
||||
try {
|
||||
final localPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
|
||||
|
||||
if (isLocalPlayer) {
|
||||
_isLocalPlayerPowered = !_isLocalPlayerPowered;
|
||||
if (!_isLocalPlayerPowered) {
|
||||
await _localPlayer.stop();
|
||||
}
|
||||
await _reportLocalPlayerState();
|
||||
await refreshPlayers();
|
||||
} else {
|
||||
final player = _availablePlayers.firstWhere(
|
||||
(p) => p.playerId == playerId,
|
||||
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
|
||||
? _selectedPlayer!
|
||||
: throw Exception("Player not found"),
|
||||
);
|
||||
|
||||
await _api!.setPower(playerId, !player.powered);
|
||||
await refreshPlayers();
|
||||
}
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Toggle power', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.setVolume(playerId, volumeLevel);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set volume', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setMute(String playerId, bool muted) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.setMute(playerId, muted);
|
||||
await refreshPlayers();
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set mute', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(String playerId, int position) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.seek(playerId, position);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Seek', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleShuffle(String queueId) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.toggleShuffle(queueId);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Toggle shuffle', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setRepeatMode(String queueId, String mode) async {
|
||||
if (_api == null) return;
|
||||
try {
|
||||
await _api!.setRepeatMode(queueId, mode);
|
||||
} catch (e) {
|
||||
ErrorHandler.logError('Set repeat mode', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
|
||||
String nextMode;
|
||||
switch (currentMode) {
|
||||
case 'off':
|
||||
case null:
|
||||
nextMode = 'all';
|
||||
break;
|
||||
case 'all':
|
||||
nextMode = 'one';
|
||||
break;
|
||||
case 'one':
|
||||
nextMode = 'off';
|
||||
break;
|
||||
default:
|
||||
nextMode = 'off';
|
||||
}
|
||||
await setRepeatMode(queueId, nextMode);
|
||||
}
|
||||
|
||||
void clearState() {
|
||||
_playerStateTimer?.cancel();
|
||||
_playerStateTimer = null;
|
||||
_localPlayerStateReportTimer?.cancel();
|
||||
_localPlayerEventSubscription?.cancel();
|
||||
_playerUpdatedEventSubscription?.cancel();
|
||||
_availablePlayers = [];
|
||||
_selectedPlayer = null;
|
||||
_currentTrack = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
clearState();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
|
||||
/// Callback type for when sleep timer expires and should pause playback
|
||||
typedef OnSleepTimerExpired = void Function();
|
||||
|
||||
/// Provider for sleep timer functionality.
|
||||
///
|
||||
/// Extracted from MusicAssistantProvider to reduce complexity and improve testability.
|
||||
/// This provider manages:
|
||||
/// - Timed sleep (pause after X minutes)
|
||||
/// - End of track sleep (pause when current track ends)
|
||||
class SleepTimerProvider with ChangeNotifier {
|
||||
final DebugLogger _logger = DebugLogger();
|
||||
|
||||
Timer? _sleepTimer;
|
||||
DateTime? _sleepTimerEndTime;
|
||||
int? _sleepTimerMinutes; // null = off, -1 = end of track, positive = minutes
|
||||
Timer? _sleepTimerDisplayTimer;
|
||||
|
||||
/// Callback to pause playback when timer expires
|
||||
OnSleepTimerExpired? onExpired;
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS
|
||||
// ============================================================================
|
||||
|
||||
/// Whether a sleep timer is currently active
|
||||
bool get isActive => _sleepTimerMinutes != null;
|
||||
|
||||
/// Current timer setting: null = off, -1 = end of track, positive = minutes
|
||||
int? get minutes => _sleepTimerMinutes;
|
||||
|
||||
/// Time remaining until sleep timer expires (null if end-of-track mode or inactive)
|
||||
Duration? get remaining {
|
||||
if (_sleepTimerEndTime == null) return null;
|
||||
final remaining = _sleepTimerEndTime!.difference(DateTime.now());
|
||||
return remaining.isNegative ? Duration.zero : remaining;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIMER CONTROL
|
||||
// ============================================================================
|
||||
|
||||
/// Set sleep timer. Pass null to turn off, -1 for end of track, or minutes.
|
||||
void setTimer(int? minutes) {
|
||||
// Cancel any existing timer
|
||||
_cancelInternal();
|
||||
|
||||
_sleepTimerMinutes = minutes;
|
||||
|
||||
if (minutes == null) {
|
||||
// Timer off
|
||||
_logger.log('😴 Sleep timer: OFF');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (minutes == -1) {
|
||||
// End of track - handled in player state updates
|
||||
_sleepTimerEndTime = null; // No fixed end time
|
||||
_logger.log('😴 Sleep timer: End of track');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set timer for specified minutes
|
||||
_sleepTimerEndTime = DateTime.now().add(Duration(minutes: minutes));
|
||||
_logger.log('😴 Sleep timer: $minutes minutes (until $_sleepTimerEndTime)');
|
||||
|
||||
// Create the actual timer
|
||||
_sleepTimer = Timer(Duration(minutes: minutes), _onTimerExpired);
|
||||
|
||||
// Start display update timer (every second for countdown)
|
||||
_sleepTimerDisplayTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
notifyListeners(); // Update UI with remaining time
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cancel sleep timer
|
||||
void cancel() {
|
||||
_cancelInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
_logger.log('😴 Sleep timer: Cancelled');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _cancelInternal() {
|
||||
_sleepTimer?.cancel();
|
||||
_sleepTimer = null;
|
||||
_sleepTimerDisplayTimer?.cancel();
|
||||
_sleepTimerDisplayTimer = null;
|
||||
}
|
||||
|
||||
void _onTimerExpired() {
|
||||
_logger.log('😴 Sleep timer expired - pausing playback');
|
||||
_cancelInternal();
|
||||
_sleepTimerMinutes = null;
|
||||
_sleepTimerEndTime = null;
|
||||
|
||||
// Trigger the callback to pause playback
|
||||
onExpired?.call();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called when track ends - checks if "end of track" sleep timer is active
|
||||
void checkEndOfTrack() {
|
||||
if (_sleepTimerMinutes == -1) {
|
||||
_logger.log('😴 End of track sleep timer triggered');
|
||||
_sleepTimerMinutes = null;
|
||||
|
||||
// Trigger the callback to pause playback
|
||||
onExpired?.call();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelInternal();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
|
||||
class AlbumDetailsScreen extends StatefulWidget {
|
||||
@@ -35,6 +37,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 +54,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 +143,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 +175,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 +217,132 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle library status
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.log('Adding album to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'album',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add album to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'album',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove album from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} 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 +389,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;
|
||||
}
|
||||
|
||||
@@ -427,12 +558,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addAlbumToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -507,12 +638,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Text(
|
||||
S.of(context)!.addToQueueOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (players.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
@@ -558,7 +689,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -570,8 +701,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
void _navigateToArtist() {
|
||||
// Navigate to the first artist if available
|
||||
if (widget.album.artists != null && widget.album.artists!.isNotEmpty) {
|
||||
final artist = widget.album.artists!.first;
|
||||
if (_displayAlbum.artists != null && _displayAlbum.artists!.isNotEmpty) {
|
||||
final artist = _displayAlbum.artists!.first;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||
Navigator.push(
|
||||
@@ -703,10 +834,16 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 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(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 350, // Increased height for bigger art
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.background,
|
||||
leading: IconButton(
|
||||
@@ -724,35 +861,64 @@ 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: 280, // Increased size
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
// 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: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
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,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.album.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.album.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(
|
||||
Icons.album_rounded,
|
||||
size: 120,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -762,7 +928,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: [
|
||||
@@ -779,7 +945,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
Hero(
|
||||
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Material(
|
||||
@@ -790,7 +956,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(
|
||||
widget.album.artistsString,
|
||||
_displayAlbum.artistsString,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.7),
|
||||
),
|
||||
@@ -799,7 +965,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
// Album Description
|
||||
if (_albumDescription != null && _albumDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
@@ -821,7 +987,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
@@ -892,7 +1058,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25), // Circular
|
||||
borderRadius: BorderRadius.circular(Radii.xxl), // Circular
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -903,17 +1069,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1055,7 +1236,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -1078,23 +1259,23 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
childCount: _tracks.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayAlbumFromHereMenu(BuildContext context, int startIndex) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
_showPlayOnSheet(
|
||||
context,
|
||||
players,
|
||||
onPlayerSelected: (player) {
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.playTracks(
|
||||
await maProvider.playTracks(
|
||||
player.playerId,
|
||||
_tracks,
|
||||
startIndex: startIndex,
|
||||
@@ -1105,97 +1286,29 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
|
||||
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.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.playRadio(player.playerId, track);
|
||||
await maProvider.playRadio(player.playerId, track);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
_showPlayOnSheet(
|
||||
context,
|
||||
players,
|
||||
onPlayerSelected: (player) {
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToPlayAlbum,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.playTracks(player.playerId, _tracks);
|
||||
await maProvider.playTracks(player.playerId, _tracks);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared "Play on..." bottom sheet with fixed height and scrollable list
|
||||
void _showPlayOnSheet(
|
||||
BuildContext context,
|
||||
List players, {
|
||||
required void Function(dynamic player) onPlayerSelected,
|
||||
}) {
|
||||
// Slide mini player down out of the way
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
final sheetHeight = (screenHeight * 0.45) + bottomPadding; // Fixed 45% + safe area
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => Container(
|
||||
height: sheetHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
S.of(context)!.playOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: players.isEmpty
|
||||
? Center(
|
||||
child: Text(S.of(context)!.noPlayersAvailable),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.only(bottom: bottomPadding + 16),
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
// Slide mini player back up when sheet is dismissed
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
import 'album_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
@@ -13,6 +14,7 @@ import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class ArtistDetailsScreen extends StatefulWidget {
|
||||
final Artist artist;
|
||||
@@ -36,11 +38,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 +56,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 +71,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 +226,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 +257,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,148 +297,209 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startArtistRadio() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
/// 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;
|
||||
}
|
||||
|
||||
if (selectedPlayer == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
/// Toggle library status
|
||||
Future<void> _toggleLibrary() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
try {
|
||||
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.startingRadio(widget.artist.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
final newState = !_isInLibrary;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.log('Adding artist to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'artist',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add artist to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'artist',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove artist from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error starting artist radio: $e');
|
||||
_logger.log('Error toggling artist library: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
content: Text('Failed to update library: $e'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
void _showRadioMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.startRadioOn(widget.artist.name),
|
||||
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: colorScheme.onSurface,
|
||||
),
|
||||
title: Text(player.name),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error starting artist radio on player: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForRadio,
|
||||
onPlayerSelected: (player) async {
|
||||
try {
|
||||
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error starting artist radio on player: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addArtistRadioToQueue() async {
|
||||
void _showAddToQueueMenu(BuildContext context) {
|
||||
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 {
|
||||
// For adding to queue with radio mode, we use radio_mode but with 'add' option
|
||||
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding artist radio to queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerToAddToQueue,
|
||||
onPlayerSelected: (player) async {
|
||||
final api = maProvider.api;
|
||||
if (api == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.notConnected)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.playArtistRadioToQueue(player.playerId, widget.artist);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error adding artist radio to queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadArtistImage() async {
|
||||
@@ -526,10 +641,16 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 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(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 300,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.background,
|
||||
leading: IconButton(
|
||||
@@ -549,24 +670,28 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceVariant,
|
||||
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: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.person_rounded,
|
||||
size: 100,
|
||||
size: coverSize * 0.5,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.person_rounded,
|
||||
size: 100,
|
||||
size: coverSize * 0.5,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -595,7 +720,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
|
||||
InkWell(
|
||||
onTap: () {
|
||||
@@ -616,7 +741,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
],
|
||||
// Action Buttons Row
|
||||
Row(
|
||||
@@ -627,7 +752,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _startArtistRadio,
|
||||
onPressed: () => _showRadioMenu(context),
|
||||
icon: const Icon(Icons.radio),
|
||||
label: Text(S.of(context)!.radio),
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -640,23 +765,6 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// "Play on..." Button (Square)
|
||||
SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _showPlayOnMenu(context),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.speaker_group_outlined),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -665,7 +773,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _addArtistRadioToQueue,
|
||||
onPressed: () => _showAddToQueueMenu(context),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -687,7 +795,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -698,9 +806,32 @@ 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),
|
||||
Spacing.vGap16,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -750,7 +881,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _toggleSortOrder,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
// View mode toggle
|
||||
IconButton(
|
||||
@@ -771,7 +902,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -807,7 +938,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _toggleSortOrder,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
@@ -827,7 +958,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -835,10 +966,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
),
|
||||
_buildAlbumSliver(_providerAlbums),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)), // Space for bottom nav + mini player
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -872,40 +1005,49 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0, // Square album art
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (album.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: album.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: Material(
|
||||
|
||||
@@ -5,15 +5,14 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'audiobook_detail_screen.dart';
|
||||
import '../widgets/provider_icon.dart';
|
||||
|
||||
class AudiobookAuthorScreen extends StatefulWidget {
|
||||
final String authorName;
|
||||
@@ -34,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
final _logger = DebugLogger();
|
||||
late List<Audiobook> _audiobooks;
|
||||
ColorScheme? _lightColorScheme;
|
||||
ColorScheme? _darkColorScheme;
|
||||
@@ -150,10 +148,16 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive cover size: 70% of screen width, clamped between 160-280 (same as artist detail)
|
||||
final coverSize = (constraints.maxWidth * 0.7).clamp(160.0, 280.0);
|
||||
final expandedHeight = coverSize + 100;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: IconButton(
|
||||
@@ -172,37 +176,41 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
Hero(
|
||||
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _authorImageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _authorImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: 200,
|
||||
height: 200,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Icon always present underneath
|
||||
Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: coverSize * 0.5,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
// Image covers icon when loaded
|
||||
if (_authorImageUrl != null)
|
||||
SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
child: ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: _authorImageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
memCacheWidth: 256,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.accountOutline,
|
||||
size: 100,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -287,8 +295,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
),
|
||||
),
|
||||
_buildAudiobookSliver(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -395,6 +405,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,12 +5,14 @@ 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/provider_icon.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class AudiobookDetailScreen extends StatefulWidget {
|
||||
final Audiobook audiobook;
|
||||
@@ -139,7 +141,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 +171,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 +211,18 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
|
||||
void _showPlayOnMenu(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final players = maProvider.availablePlayers;
|
||||
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
final sheetHeight = (screenHeight * 0.45) + bottomPadding;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => Container(
|
||||
height: sheetHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
S.of(context)!.playOn,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: players.isEmpty
|
||||
? Center(child: Text(S.of(context)!.noPlayersAvailable))
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.only(bottom: bottomPadding + 16),
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).whenComplete(() {
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
GlobalPlayerOverlay.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForAudiobook,
|
||||
onPlayerSelected: (player) async {
|
||||
maProvider.selectPlayer(player);
|
||||
maProvider.setCurrentAudiobook(_audiobook);
|
||||
await maProvider.api?.playAudiobook(
|
||||
player.playerId,
|
||||
widget.audiobook,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markAsFinished() async {
|
||||
@@ -466,10 +421,16 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 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(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 350,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: IconButton(
|
||||
@@ -537,38 +498,55 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: 120,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: 120,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: 120,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
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.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
errorWidget: (_, __, ___) => Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
size: coverSize * 0.43,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.audiobook.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.audiobook.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -596,7 +574,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
|
||||
// Author
|
||||
Text(
|
||||
@@ -618,7 +596,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
],
|
||||
|
||||
// Duration & Progress
|
||||
const SizedBox(height: 16),
|
||||
Spacing.vGap16,
|
||||
Row(
|
||||
children: [
|
||||
if (book.duration != null) ...[
|
||||
@@ -742,7 +720,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -766,7 +744,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -797,7 +775,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Spacing.vGap8,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -807,8 +785,10 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
|
||||
// Use MA chapters if available, otherwise use ABS chapters
|
||||
..._buildChapterSection(book, colorScheme, textTheme),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: BottomSpacing.withMiniPlayer)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:palette_generator/palette_generator.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../widgets/global_player_overlay.dart' show BottomSpacing;
|
||||
import '../widgets/provider_icon.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
@@ -275,11 +276,17 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: CustomScrollView(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 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(
|
||||
slivers: [
|
||||
// App bar with series cover collage (matches audiobook detail screen)
|
||||
SliverAppBar(
|
||||
expandedHeight: 350,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
leading: IconButton(
|
||||
@@ -292,12 +299,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
// Series cover collage - same size as audiobook detail cover
|
||||
// Series cover collage - responsive size
|
||||
Hero(
|
||||
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
||||
child: Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -445,10 +452,12 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
_buildAudiobookSliver(maProvider),
|
||||
|
||||
// Bottom padding for mini player
|
||||
const SliverToBoxAdapter(
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -682,6 +691,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
),
|
||||
// Progress indicator
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
@@ -740,45 +754,55 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
MdiIcons.bookOutline,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: book.progress,
|
||||
backgroundColor: Colors.black54,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Provider icon overlay
|
||||
if (book.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: book.providerMappings!.first.providerDomain,
|
||||
size: 16,
|
||||
),
|
||||
if (book.progress > 0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: book.progress,
|
||||
backgroundColor: Colors.black54,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -41,10 +41,12 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
|
||||
|
||||
void _copyLogs() {
|
||||
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
||||
// Copy only the filtered entries that are currently displayed
|
||||
final filteredLogs = _filteredEntries.map((e) => e.formatted).join('\n');
|
||||
Clipboard.setData(ClipboardData(text: filteredLogs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.logsCopied),
|
||||
content: Text('${S.of(context)!.logsCopied} (${_filteredEntries.length} entries)'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -61,6 +63,8 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||
report,
|
||||
subject: S.of(context)!.ensembleBugReport,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.error('Share failed', error: e);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isGeneratingReport = false);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -4,8 +4,12 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import 'playlist_details_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
|
||||
class LibraryPlaylistsScreen extends StatefulWidget {
|
||||
const LibraryPlaylistsScreen({super.key});
|
||||
@@ -30,17 +34,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 +87,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,
|
||||
@@ -110,34 +108,55 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Unique suffix for this context to avoid hero tag conflicts
|
||||
const heroSuffix = '_playlists';
|
||||
|
||||
return RepaintBoundary(
|
||||
child: ListTile(
|
||||
key: ValueKey(playlist.itemId),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
leading: Hero(
|
||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
memCacheHeight: 96,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => const SizedBox(),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
playlist.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
title: Hero(
|
||||
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + heroSuffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
playlist.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
playlist.trackCount != null
|
||||
@@ -150,16 +169,17 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: playlist.favorite == true
|
||||
? const Icon(Icons.favorite, color: Colors.red, size: 20)
|
||||
? const Icon(Icons.favorite, color: StatusColors.favorite, size: 20)
|
||||
: null,
|
||||
onTap: () {
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetailsScreen(
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
heroTagSuffix: 'playlists',
|
||||
initialImageUrl: imageUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -5,10 +5,12 @@ import '../services/settings_service.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/profile_service.dart';
|
||||
import '../services/auth/auth_strategy.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../widgets/debug/debug_console.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'home_screen.dart';
|
||||
import 'qr_scanner_screen.dart';
|
||||
|
||||
enum ConnectionMode { local, remote }
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@@ -24,6 +26,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
|
||||
final TextEditingController _usernameController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _remoteIdController = TextEditingController(); // Remote Access ID
|
||||
final FocusNode _usernameFocusNode = FocusNode();
|
||||
|
||||
bool _isConnecting = false;
|
||||
@@ -36,6 +39,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
|
||||
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
|
||||
|
||||
// Remote connection state
|
||||
ConnectionMode _connectionMode = ConnectionMode.local;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -74,7 +80,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_authServerUrlController.dispose();
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_remoteIdController.dispose();
|
||||
_usernameFocusNode.dispose();
|
||||
// Note: Don't stop _remoteBridge here - it needs to stay alive for the session
|
||||
// It will be stopped when the app closes or user disconnects
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -443,6 +452,21 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
|
||||
if (provider.isConnected) {
|
||||
// Clear remote access ID since this is a direct/local connection
|
||||
await SettingsService.clearRemoteAccessId();
|
||||
|
||||
// For first-time users, wait for player selection so the welcome
|
||||
// overlay can appear immediately without home screen flash.
|
||||
// This matches the logic in AppStartup._checkAndConnect().
|
||||
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
if (!hasCompletedOnboarding) {
|
||||
// First-time user: wait for connection + player selection
|
||||
for (int i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
if (provider.selectedPlayer != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to home screen
|
||||
if (mounted) {
|
||||
// Ensure keyboard is closed before navigating
|
||||
@@ -465,6 +489,104 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect via remote bridge (WebRTC tunnel)
|
||||
Future<void> _connectRemote() async {
|
||||
final remoteId = _remoteIdController.text.trim();
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
|
||||
if (remoteId.isEmpty) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.pleaseEnterRemoteId;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.pleaseEnterCredentials;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
_addDebugLog('Starting remote connection to: $remoteId');
|
||||
|
||||
try {
|
||||
// Start the remote bridge via provider (WebView is already in widget tree)
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final port = await provider.startRemoteBridge(remoteId, username, password);
|
||||
|
||||
if (port == null) {
|
||||
setState(() {
|
||||
_error = S.of(context)!.remoteConnectionFailed;
|
||||
_isConnecting = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_addDebugLog('Remote bridge started on port $port');
|
||||
|
||||
// Save credentials BEFORE connecting - needed for MA authentication via bridge
|
||||
await SettingsService.setUsername(username);
|
||||
await SettingsService.setPassword(password);
|
||||
|
||||
// Connect to the local bridge endpoint
|
||||
final serverUrl = 'ws://localhost:$port';
|
||||
|
||||
_addDebugLog('Connecting to bridge at $serverUrl');
|
||||
// Pass remoteId as cloud URL for Sendspin to connect directly
|
||||
await provider.connectToServer(serverUrl, isRemoteMode: true, remoteCloudUrl: remoteId);
|
||||
|
||||
// Wait for connection
|
||||
for (int i = 0; i < 10; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (provider.isConnected) break;
|
||||
}
|
||||
|
||||
if (provider.isConnected) {
|
||||
_addDebugLog('Connected successfully via remote bridge');
|
||||
|
||||
// Save remote access ID and credentials for future auto-reconnect
|
||||
await SettingsService.setRemoteAccessId(remoteId);
|
||||
await SettingsService.setUsername(username);
|
||||
await SettingsService.setPassword(password);
|
||||
|
||||
// For first-time users, wait for player selection
|
||||
final hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||
if (!hasCompletedOnboarding) {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
if (provider.selectedPlayer != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to home screen
|
||||
if (mounted) {
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_error = S.of(context)!.connectionFailed;
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_addDebugLog('Remote connection error: $e');
|
||||
setState(() {
|
||||
_error = 'Remote connection failed: ${e.toString()}';
|
||||
_isConnecting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -497,9 +619,260 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Server URL
|
||||
// Connection Mode Toggle
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _isConnecting ? null : () {
|
||||
setState(() {
|
||||
_connectionMode = ConnectionMode.local;
|
||||
_error = null;
|
||||
_detectedAuthStrategy = null;
|
||||
_detectedAuthType = null;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: _connectionMode == ConnectionMode.local
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.home_rounded,
|
||||
size: 20,
|
||||
color: _connectionMode == ConnectionMode.local
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
S.of(context)!.localConnection,
|
||||
style: TextStyle(
|
||||
color: _connectionMode == ConnectionMode.local
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontWeight: _connectionMode == ConnectionMode.local
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _isConnecting ? null : () {
|
||||
setState(() {
|
||||
_connectionMode = ConnectionMode.remote;
|
||||
_error = null;
|
||||
_detectedAuthStrategy = null;
|
||||
_detectedAuthType = null;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: _connectionMode == ConnectionMode.remote
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_rounded,
|
||||
size: 20,
|
||||
color: _connectionMode == ConnectionMode.remote
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
S.of(context)!.remoteConnection,
|
||||
style: TextStyle(
|
||||
color: _connectionMode == ConnectionMode.remote
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontWeight: _connectionMode == ConnectionMode.remote
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// REMOTE MODE: Remote Access ID + Credentials
|
||||
if (_connectionMode == ConnectionMode.remote) ...[
|
||||
Text(
|
||||
S.of(context)!.remoteAccessId,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.remoteAccessIdHint,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _remoteIdController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'ABCD1234-EFGH5-6IJKL-MNOP7890',
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.vpn_key_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
enabled: !_isConnecting,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.qr_code_scanner_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
onPressed: _isConnecting ? null : () async {
|
||||
final result = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const QrScannerScreen(),
|
||||
),
|
||||
);
|
||||
if (result != null && result.isNotEmpty) {
|
||||
_remoteIdController.text = result;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Username for remote
|
||||
Text(
|
||||
S.of(context)!.username,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
focusNode: _connectionMode == ConnectionMode.remote ? _usernameFocusNode : null,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: S.of(context)!.username,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.person_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
enabled: !_isConnecting,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Password for remote
|
||||
Text(
|
||||
S.of(context)!.password,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: S.of(context)!.password,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_rounded,
|
||||
color: colorScheme.onSurface.withOpacity(0.54),
|
||||
),
|
||||
),
|
||||
obscureText: true,
|
||||
enabled: !_isConnecting,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _connectRemote(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// LOCAL MODE: Server URL, Port, etc.
|
||||
if (_connectionMode == ConnectionMode.local) ...[
|
||||
Text(
|
||||
S.of(context)!.serverAddress,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
@@ -844,6 +1217,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
], // End of local mode conditional
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -890,7 +1264,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
ElevatedButton(
|
||||
onPressed: (_isConnecting || _isDetectingAuth)
|
||||
? null
|
||||
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
|
||||
: _connectionMode == ConnectionMode.remote
|
||||
? _connectRemote
|
||||
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
@@ -932,7 +1308,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
|
||||
_connectionMode == ConnectionMode.remote
|
||||
? S.of(context)!.connect
|
||||
: (_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
@@ -13,6 +14,9 @@ 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/podcast_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -35,6 +39,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
bool _showFavoritePodcasts = false;
|
||||
// Audiobook rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
@@ -45,19 +52,89 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
// Track if we had empty favorites on first load (to know when to refresh)
|
||||
bool _hadEmptyFavoritesOnLoad = false;
|
||||
SyncStatus? _lastSyncStatus;
|
||||
int _lastArtistCount = 0;
|
||||
MusicAssistantProvider? _providerListeningTo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_loadSettings();
|
||||
// Listen to sync completion to refresh favorite rows when data becomes available
|
||||
SyncService.instance.addListener(_onSyncChanged);
|
||||
_lastSyncStatus = SyncService.instance.status;
|
||||
_checkInitialFavoriteState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SyncService.instance.removeListener(_onSyncChanged);
|
||||
// Clean up provider listener if attached
|
||||
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Check if favorites are empty on initial load (so we know to refresh after sync)
|
||||
void _checkInitialFavoriteState() {
|
||||
// Schedule after first frame to ensure provider is accessible
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
_lastArtistCount = provider.artists.length;
|
||||
// Check if artists list is empty - favorites will also be empty
|
||||
if (_lastArtistCount == 0) {
|
||||
_hadEmptyFavoritesOnLoad = true;
|
||||
_logger.log('📋 Home: favorites empty on load, will refresh after sync');
|
||||
// Also listen to provider for when cache loads (before sync completes)
|
||||
_providerListeningTo = provider;
|
||||
provider.addListener(_onProviderChanged);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Called when MusicAssistantProvider changes (for cache load detection)
|
||||
void _onProviderChanged() {
|
||||
if (!mounted || !_hadEmptyFavoritesOnLoad || _providerListeningTo == null) return;
|
||||
final currentCount = _providerListeningTo!.artists.length;
|
||||
// If artists went from 0 to non-zero, refresh
|
||||
if (_lastArtistCount == 0 && currentCount > 0) {
|
||||
_logger.log('🔄 Home: artists loaded from cache ($currentCount), refreshing rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
_providerListeningTo!.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
}
|
||||
_lastArtistCount = currentCount;
|
||||
}
|
||||
|
||||
/// Called when SyncService status changes
|
||||
void _onSyncChanged() {
|
||||
final newStatus = SyncService.instance.status;
|
||||
// When sync completes and we had empty favorites, refresh to show new data
|
||||
if (_lastSyncStatus == SyncStatus.syncing &&
|
||||
newStatus == SyncStatus.completed &&
|
||||
_hadEmptyFavoritesOnLoad) {
|
||||
_logger.log('🔄 Home: sync completed, refreshing favorite rows');
|
||||
_hadEmptyFavoritesOnLoad = false;
|
||||
// Clean up provider listener if still attached
|
||||
_providerListeningTo?.removeListener(_onProviderChanged);
|
||||
_providerListeningTo = null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_refreshKey = UniqueKey();
|
||||
});
|
||||
}
|
||||
}
|
||||
_lastSyncStatus = newStatus;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Reload settings when app resumes (coming back from settings)
|
||||
@@ -73,6 +150,9 @@ 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 showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
|
||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
@@ -85,6 +165,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showFavoritePodcasts = showFavPodcasts;
|
||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
@@ -144,6 +227,21 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
titleSpacing: 0,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
// Copy logs button for debugging
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report_outlined, size: 20),
|
||||
onPressed: () {
|
||||
final logs = DebugLogger().entries.map((e) => e.formatted).join('\n');
|
||||
Clipboard.setData(ClipboardData(text: logs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${DebugLogger().entries.length} log entries copied'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Copy logs',
|
||||
),
|
||||
// Sync indicator - shows when library is syncing in background
|
||||
ListenableBuilder(
|
||||
listenable: SyncService.instance,
|
||||
@@ -194,7 +292,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)
|
||||
@@ -238,15 +336,50 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
}
|
||||
|
||||
|
||||
/// Count how many rows are currently enabled
|
||||
int _countEnabledRows() {
|
||||
int count = 0;
|
||||
for (final rowId in _homeRowOrder) {
|
||||
if (_isRowEnabled(rowId)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Check if a specific row is enabled
|
||||
bool _isRowEnabled(String rowId) {
|
||||
switch (rowId) {
|
||||
case 'recent-albums': return _showRecentAlbums;
|
||||
case 'discover-artists': return _showDiscoverArtists;
|
||||
case 'discover-albums': return _showDiscoverAlbums;
|
||||
case 'continue-listening': return _showContinueListeningAudiobooks;
|
||||
case 'discover-audiobooks': return _showDiscoverAudiobooks;
|
||||
case 'discover-series': return _showDiscoverSeries;
|
||||
case 'favorite-albums': return _showFavoriteAlbums;
|
||||
case 'favorite-artists': return _showFavoriteArtists;
|
||||
case 'favorite-tracks': return _showFavoriteTracks;
|
||||
case 'favorite-playlists': return _showFavoritePlaylists;
|
||||
case 'favorite-radio-stations': return _showFavoriteRadioStations;
|
||||
case 'favorite-podcasts': return _showFavoritePodcasts;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildConnectedView(
|
||||
BuildContext context, MusicAssistantProvider provider) {
|
||||
// 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;
|
||||
|
||||
// Account for margins between rows (2px each)
|
||||
// Only adjust for margins when ≤3 rows (so they fit exactly without scroll)
|
||||
// For 4+ rows, use original calculation so 4th row stays hidden (scrollable)
|
||||
const marginSize = 2.0;
|
||||
final enabledRows = _countEnabledRows();
|
||||
final marginsInView = enabledRows > 1 && enabledRows <= 3 ? (enabledRows - 1) * marginSize : 0.0;
|
||||
final rowHeight = (availableHeight - marginsInView) / 3;
|
||||
|
||||
// Use Android 12+ stretch overscroll effect
|
||||
return NotificationListener<ScrollNotification>(
|
||||
@@ -288,6 +421,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
for (final rowId in _homeRowOrder) {
|
||||
final widget = _buildRowWidget(rowId, provider, rowHeight);
|
||||
if (widget != null) {
|
||||
// Add spacing between rows (not before first row)
|
||||
if (rows.isNotEmpty) {
|
||||
rows.add(const SizedBox(height: 2.0));
|
||||
}
|
||||
rows.add(widget);
|
||||
}
|
||||
}
|
||||
@@ -330,7 +467,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 +476,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 +485,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 +513,33 @@ 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,
|
||||
);
|
||||
case 'favorite-podcasts':
|
||||
if (!_showFavoritePodcasts) return null;
|
||||
return PodcastRow(
|
||||
key: const ValueKey('favorite-podcasts'),
|
||||
title: S.of(context)!.favoritePodcasts,
|
||||
loadPodcasts: () => provider.getFavoritePodcasts(),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
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/provider_icon.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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.addedToLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.log('Adding podcast to library: provider=$actualProvider, itemId=$actualItemId');
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.addToLibrary(
|
||||
mediaType: 'podcast',
|
||||
provider: actualProvider,
|
||||
itemId: actualItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to add podcast to library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update UI immediately
|
||||
setState(() {
|
||||
_isInLibrary = newState;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.removedFromLibrary),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fire and forget - API call happens in background
|
||||
maProvider.removeFromLibrary(
|
||||
mediaType: 'podcast',
|
||||
libraryItemId: libraryItemId,
|
||||
).catchError((e) {
|
||||
_logger.log('❌ Failed to remove podcast from library: $e');
|
||||
// Revert on failure
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInLibrary = !newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} 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) {
|
||||
List<MediaItem> episodes = [];
|
||||
|
||||
// Try loading with the podcast's own ID and provider first
|
||||
try {
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
widget.podcast.itemId,
|
||||
provider: widget.podcast.provider,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Primary episode load failed: $e');
|
||||
}
|
||||
|
||||
// If that failed and we have provider mappings, try those
|
||||
if (episodes.isEmpty && widget.podcast.providerMappings != null) {
|
||||
for (final mapping in widget.podcast.providerMappings!) {
|
||||
if (mapping.itemId.isNotEmpty && mapping.providerInstance.isNotEmpty) {
|
||||
try {
|
||||
_logger.log('🎙️ Trying provider mapping: ${mapping.providerInstance}');
|
||||
episodes = await maProvider.api!.getPodcastEpisodes(
|
||||
mapping.itemId,
|
||||
provider: mapping.providerInstance,
|
||||
);
|
||||
if (episodes.isNotEmpty) {
|
||||
_logger.log('🎙️ Loaded episodes via provider mapping');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('🎙️ Provider mapping failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.showPlayerSelectorForAction(
|
||||
contextHint: S.of(context)!.selectPlayerForEpisode,
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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: SizedBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Provider icon overlay
|
||||
if (widget.podcast.providerMappings?.isNotEmpty == true)
|
||||
ProviderIconOverlay(
|
||||
domain: widget.podcast.providerMappings!.first.providerDomain,
|
||||
size: 24,
|
||||
margin: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// A screen for scanning QR codes to get the Remote Access ID.
|
||||
class QrScannerScreen extends StatefulWidget {
|
||||
const QrScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QrScannerScreen> createState() => _QrScannerScreenState();
|
||||
}
|
||||
|
||||
class _QrScannerScreenState extends State<QrScannerScreen> {
|
||||
late final MobileScannerController _controller;
|
||||
bool _hasScanned = false;
|
||||
String _statusMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.normal,
|
||||
facing: CameraFacing.back,
|
||||
formats: [BarcodeFormat.qrCode], // Only scan QR codes
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_hasScanned) return;
|
||||
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
for (final barcode in barcodes) {
|
||||
final String? code = barcode.rawValue;
|
||||
if (code != null && code.isNotEmpty) {
|
||||
final remoteId = _extractRemoteId(code);
|
||||
|
||||
if (remoteId != null && remoteId.isNotEmpty) {
|
||||
_hasScanned = true;
|
||||
Navigator.of(context).pop(remoteId);
|
||||
return;
|
||||
} else {
|
||||
// Show what was scanned for debugging
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Scanned: $code'),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the remote ID from various QR code formats
|
||||
String? _extractRemoteId(String code) {
|
||||
// Format 1: Raw remote ID (alphanumeric)
|
||||
if (RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(code) && code.length >= 8) {
|
||||
return code;
|
||||
}
|
||||
|
||||
// Format 2: ma://remote/<id>
|
||||
if (code.startsWith('ma://remote/')) {
|
||||
return code.replaceFirst('ma://remote/', '');
|
||||
}
|
||||
|
||||
// Format 3: URL from app.music-assistant.io
|
||||
// Examples:
|
||||
// - https://app.music-assistant.io/?cloudInstanceId=<id>
|
||||
// - https://app.music-assistant.io/#/home?cloudInstanceId=<id>
|
||||
if (code.contains('music-assistant.io')) {
|
||||
final uri = Uri.tryParse(code);
|
||||
if (uri != null) {
|
||||
// Check query parameters in main URL
|
||||
final queryParams = ['cloudInstanceId', 'cloud_instance_id', 'remoteid', 'remote_id', 'id'];
|
||||
for (final param in queryParams) {
|
||||
final value = uri.queryParameters[param];
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check fragment (hash) for query params: #/home?cloudInstanceId=xxx
|
||||
final fragment = uri.fragment;
|
||||
if (fragment.contains('?')) {
|
||||
final fragmentQuery = fragment.split('?').last;
|
||||
final fragmentParams = Uri.splitQueryString(fragmentQuery);
|
||||
for (final param in queryParams) {
|
||||
final value = fragmentParams[param];
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ID is in path segments
|
||||
for (final segment in uri.pathSegments) {
|
||||
if (segment.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(segment)) {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format 4: Other URL formats - try to extract from query params or path
|
||||
if (code.contains('://')) {
|
||||
final uri = Uri.tryParse(code);
|
||||
if (uri != null) {
|
||||
// Check common query param names
|
||||
for (final param in ['id', 'remoteid', 'remote_id', 'cloudInstanceId']) {
|
||||
final value = uri.queryParameters[param];
|
||||
if (value != null && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
// Last path segment if it looks like an ID
|
||||
if (uri.pathSegments.isNotEmpty) {
|
||||
final last = uri.pathSegments.last;
|
||||
if (last.length >= 8 && RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(last)) {
|
||||
return last;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(S.of(context)!.scanQrCode),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
// Toggle torch
|
||||
IconButton(
|
||||
icon: ValueListenableBuilder(
|
||||
valueListenable: _controller,
|
||||
builder: (context, state, child) {
|
||||
return Icon(
|
||||
state.torchState == TorchState.on
|
||||
? Icons.flash_on
|
||||
: Icons.flash_off,
|
||||
);
|
||||
},
|
||||
),
|
||||
onPressed: () => _controller.toggleTorch(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Camera preview
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error, child) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
size: 64,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Camera error: ${error.errorCode.name}',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.errorDetails?.message ?? S.of(context)!.cameraPermissionDenied,
|
||||
style: TextStyle(color: colorScheme.error.withOpacity(0.7)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Scan overlay with instruction
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Scan frame
|
||||
Container(
|
||||
width: 280,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Instruction text
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.pointCameraAtQr,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (_statusMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../widgets/common/empty_state.dart';
|
||||
import '../widgets/player_picker_sheet.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class QueueScreen extends StatefulWidget {
|
||||
@@ -67,6 +68,68 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleTransferQueue(MusicAssistantProvider maProvider) async {
|
||||
final player = maProvider.selectedPlayer;
|
||||
if (player == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get available players, excluding the current source player
|
||||
final allPlayers = maProvider.availablePlayers;
|
||||
final targetPlayers = allPlayers
|
||||
.where((p) => p.playerId != player.playerId && p.available)
|
||||
.toList();
|
||||
|
||||
if (targetPlayers.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.noOtherPlayersAvailable),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show player picker sheet
|
||||
await showPlayerPickerSheet(
|
||||
context: context,
|
||||
title: S.of(context)!.transferQueueTo,
|
||||
players: targetPlayers,
|
||||
onPlayerSelected: (targetPlayer) async {
|
||||
try {
|
||||
await maProvider.api?.transferQueue(
|
||||
sourceQueueId: player.playerId,
|
||||
targetQueueId: targetPlayer.playerId,
|
||||
autoPlay: true,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.queueTransferredTo(targetPlayer.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Error transferring queue: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.failedToTransferQueue(e.toString())),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
@@ -87,6 +150,11 @@ class _QueueScreenState extends State<QueueScreen> {
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_horiz_rounded),
|
||||
tooltip: S.of(context)!.transferQueue,
|
||||
onPressed: () => _handleTransferQueue(maProvider),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadQueue,
|
||||
|
||||
@@ -29,18 +29,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
bool _showFavoritePodcasts = false;
|
||||
// Audiobook home rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
bool _showDiscoverSeries = false;
|
||||
// Home row order
|
||||
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||
// Audiobook libraries
|
||||
List<Map<String, String>> _discoveredLibraries = [];
|
||||
Map<String, bool> _libraryEnabled = {};
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
bool _disableAutoSwitch = false;
|
||||
bool _volumePrecisionMode = true;
|
||||
// Hint settings
|
||||
bool _showHints = true;
|
||||
// Display settings
|
||||
bool _showProviderIcons = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -66,6 +72,9 @@ 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();
|
||||
final showFavPodcasts = await SettingsService.getShowFavoritePodcasts();
|
||||
|
||||
// Load audiobook home row settings
|
||||
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
@@ -75,19 +84,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load home row order
|
||||
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||
|
||||
// Load audiobook library settings
|
||||
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
|
||||
final enabled = await SettingsService.getEnabledAbsLibraries();
|
||||
final libraryEnabled = <String, bool>{};
|
||||
for (final lib in discovered) {
|
||||
final path = lib['path'] ?? '';
|
||||
// null means all enabled
|
||||
libraryEnabled[path] = enabled == null || enabled.contains(path);
|
||||
}
|
||||
|
||||
// Load player settings
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
|
||||
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||
|
||||
// Load hint settings
|
||||
final showHints = await SettingsService.getShowHints();
|
||||
|
||||
// Load display settings
|
||||
final showProviderIcons = await SettingsService.getShowProviderIcons();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -97,14 +104,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showFavoritePodcasts = showFavPodcasts;
|
||||
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_homeRowOrder = rowOrder;
|
||||
_discoveredLibraries = discovered;
|
||||
_libraryEnabled = libraryEnabled;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_disableAutoSwitch = disableAutoSwitch;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showProviderIcons = showProviderIcons;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,6 +150,12 @@ 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};
|
||||
case 'favorite-podcasts':
|
||||
return {'title': s.favoritePodcasts, 'subtitle': s.showFavoritePodcasts};
|
||||
default:
|
||||
return {'title': rowId, 'subtitle': ''};
|
||||
}
|
||||
@@ -164,6 +182,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return _showFavoriteArtists;
|
||||
case 'favorite-tracks':
|
||||
return _showFavoriteTracks;
|
||||
case 'favorite-playlists':
|
||||
return _showFavoritePlaylists;
|
||||
case 'favorite-radio-stations':
|
||||
return _showFavoriteRadioStations;
|
||||
case 'favorite-podcasts':
|
||||
return _showFavoritePodcasts;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -209,6 +233,18 @@ 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;
|
||||
case 'favorite-podcasts':
|
||||
_showFavoritePodcasts = value;
|
||||
SettingsService.setShowFavoritePodcasts(value);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -275,13 +311,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
|
||||
@@ -588,7 +637,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
RadioListTile<String?>(
|
||||
title: Text(S.of(context)!.system),
|
||||
subtitle: const Text('Auto'),
|
||||
subtitle: Text(S.of(context)!.auto),
|
||||
value: null,
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -597,7 +646,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('English'),
|
||||
title: Text(S.of(context)!.english),
|
||||
value: 'en',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -606,7 +655,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('Deutsch'),
|
||||
title: Text(S.of(context)!.german),
|
||||
value: 'de',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -615,7 +664,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: const Text('Español'),
|
||||
title: Text(S.of(context)!.spanish),
|
||||
value: 'es',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
@@ -623,6 +672,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(S.of(context)!.french),
|
||||
value: 'fr',
|
||||
groupValue: localeProvider.locale?.languageCode,
|
||||
onChanged: (value) {
|
||||
localeProvider.setLocale(const Locale('fr'));
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -696,6 +754,135 @@ 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(
|
||||
S.of(context)!.disableAutoSwitch,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
S.of(context)!.disableAutoSwitchDescription,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _disableAutoSwitch,
|
||||
onChanged: (value) {
|
||||
setState(() => _disableAutoSwitch = value);
|
||||
SettingsService.setDisableAutoSwitch(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
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),
|
||||
|
||||
// Hints & Tips section
|
||||
Text(
|
||||
S.of(context)!.hintsAndTips,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.showHintsDescription,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
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(
|
||||
S.of(context)!.showHints,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
value: _showHints,
|
||||
onChanged: (value) {
|
||||
setState(() => _showHints = value);
|
||||
SettingsService.setShowHints(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Display section
|
||||
Text(
|
||||
S.of(context)!.display,
|
||||
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(
|
||||
S.of(context)!.showProviderIcons,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
S.of(context)!.showProviderIconsDescription,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
value: _showProviderIcons,
|
||||
onChanged: (value) {
|
||||
setState(() => _showProviderIcons = value);
|
||||
SettingsService.setShowProviderIcons(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
@@ -875,76 +1062,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Audiobook Libraries section
|
||||
if (_discoveredLibraries.isNotEmpty) ...[
|
||||
Text(
|
||||
S.of(context)!.audiobookLibraries,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context)!.chooseAudiobookLibraries,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: _discoveredLibraries.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final library = entry.value;
|
||||
final path = library['path'] ?? '';
|
||||
final name = library['name'] ?? S.of(context)!.unknownLibrary;
|
||||
final isEnabled = _libraryEnabled[path] ?? true;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
name,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
value: isEnabled,
|
||||
onChanged: (value) async {
|
||||
setState(() {
|
||||
_libraryEnabled[path] = value;
|
||||
});
|
||||
await SettingsService.toggleAbsLibrary(path, value);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(S.of(context)!.pullToRefresh),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
),
|
||||
if (index < _discoveredLibraries.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: colorScheme.onSurface.withOpacity(0.1),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -150,6 +157,17 @@ class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
// Note: We don't actually stop playback - this button is repurposed for player switching
|
||||
}
|
||||
|
||||
/// Fully stop the foreground service and release resources
|
||||
/// Called after idle timeout to save battery
|
||||
Future<void> stopService() async {
|
||||
_logger.log('MassivAudioHandler: Stopping foreground service (idle timeout)');
|
||||
_isRemoteMode = false;
|
||||
_currentMediaItem = null;
|
||||
await _player.stop();
|
||||
// Call the base stop() to properly stop the foreground service
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) => _player.seek(position);
|
||||
|
||||
@@ -293,4 +311,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'basic_auth_strategy.dart';
|
||||
import 'authelia_strategy.dart';
|
||||
import 'ma_auth_strategy.dart';
|
||||
import '../debug_logger.dart';
|
||||
import '../../constants/network.dart';
|
||||
|
||||
/// Central authentication manager
|
||||
/// Manages auth strategy selection, auto-detection, and credential lifecycle
|
||||
@@ -65,16 +66,18 @@ class AuthManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Normalize a server URL with appropriate protocol
|
||||
/// Normalize a server URL with appropriate protocol and port
|
||||
/// For local IP addresses without an explicit port, defaults to MA port 8095
|
||||
String _normalizeUrl(String url) {
|
||||
var baseUrl = url;
|
||||
var baseUrl = url.trim();
|
||||
|
||||
// Check if URL already has a port specified (before adding protocol)
|
||||
// Port can be specified as host:port or after protocol as http://host:port
|
||||
bool hasExplicitPort = _hasExplicitPort(baseUrl);
|
||||
|
||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||
// Use http for local IPs, https for domains
|
||||
if (baseUrl.startsWith('192.') ||
|
||||
baseUrl.startsWith('10.') ||
|
||||
baseUrl.startsWith('172.') ||
|
||||
baseUrl == 'localhost' ||
|
||||
baseUrl.startsWith('127.')) {
|
||||
if (_isLocalAddress(baseUrl)) {
|
||||
baseUrl = 'http://$baseUrl';
|
||||
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
|
||||
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
|
||||
@@ -84,9 +87,80 @@ class AuthManager {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
}
|
||||
|
||||
// For local addresses without explicit port, add default MA port
|
||||
// This handles the common case of MA as HAOS add-on where port 8095 is needed
|
||||
if (!hasExplicitPort) {
|
||||
final uri = Uri.parse(baseUrl);
|
||||
if (_isLocalAddress(uri.host)) {
|
||||
baseUrl = Uri(
|
||||
scheme: uri.scheme,
|
||||
host: uri.host,
|
||||
port: NetworkConstants.defaultWsPort,
|
||||
path: uri.path.isEmpty ? null : uri.path,
|
||||
).toString();
|
||||
_logger.log('🔗 Local address without port - defaulting to MA port ${NetworkConstants.defaultWsPort}');
|
||||
}
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/// Check if a URL string has an explicit port specified
|
||||
bool _hasExplicitPort(String url) {
|
||||
// Remove protocol if present
|
||||
var hostPart = url;
|
||||
if (hostPart.startsWith('http://')) {
|
||||
hostPart = hostPart.substring(7);
|
||||
} else if (hostPart.startsWith('https://')) {
|
||||
hostPart = hostPart.substring(8);
|
||||
}
|
||||
|
||||
// Remove path if present
|
||||
final pathIndex = hostPart.indexOf('/');
|
||||
if (pathIndex != -1) {
|
||||
hostPart = hostPart.substring(0, pathIndex);
|
||||
}
|
||||
|
||||
// Check for port (host:port pattern)
|
||||
// Need to handle IPv6 addresses like [::1]:8095
|
||||
if (hostPart.startsWith('[')) {
|
||||
// IPv6 address
|
||||
final closeBracket = hostPart.indexOf(']');
|
||||
if (closeBracket != -1 && closeBracket < hostPart.length - 1) {
|
||||
return hostPart[closeBracket + 1] == ':';
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
// IPv4 or hostname - check for colon followed by digits
|
||||
final colonIndex = hostPart.lastIndexOf(':');
|
||||
if (colonIndex != -1) {
|
||||
final portPart = hostPart.substring(colonIndex + 1);
|
||||
return int.tryParse(portPart) != null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an address is a local/private address (without protocol)
|
||||
bool _isLocalAddress(String address) {
|
||||
// Remove port if present for checking
|
||||
var host = address;
|
||||
final colonIndex = host.lastIndexOf(':');
|
||||
if (colonIndex != -1 && int.tryParse(host.substring(colonIndex + 1)) != null) {
|
||||
host = host.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
return host.startsWith('192.168.') ||
|
||||
host.startsWith('192.') ||
|
||||
host.startsWith('10.') ||
|
||||
host.startsWith('172.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.ts.net');
|
||||
}
|
||||
|
||||
/// Check if a hostname is a local/private IP address
|
||||
bool _isLocalIpAddress(String host) {
|
||||
return host.startsWith('192.168.') ||
|
||||
@@ -100,7 +174,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
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import 'dart:convert';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../constants/timings.dart';
|
||||
|
||||
/// Centralized cache service for all app data caching
|
||||
/// This is a non-notifying service - it just stores and retrieves cached data
|
||||
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 = {};
|
||||
@@ -35,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
|
||||
@@ -57,6 +72,8 @@ class CacheService {
|
||||
_cachedRecentAlbums = albums;
|
||||
_recentAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} recent albums');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('recent_albums', albums.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if discover artists cache is valid
|
||||
@@ -76,6 +93,8 @@ class CacheService {
|
||||
_cachedDiscoverArtists = artists;
|
||||
_discoverArtistsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${artists.length} discover artists');
|
||||
// Persist to database for instant load on next launch
|
||||
_persistHomeRowToDatabase('discover_artists', artists.map((a) => a.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Check if discover albums cache is valid
|
||||
@@ -95,6 +114,76 @@ class CacheService {
|
||||
_cachedDiscoverAlbums = albums;
|
||||
_discoverAlbumsLastFetched = DateTime.now();
|
||||
_logger.log('✅ Cached ${albums.length} discover albums');
|
||||
// Persist to database for instant load on next launch
|
||||
_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)
|
||||
@@ -102,9 +191,42 @@ class CacheService {
|
||||
_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');
|
||||
}
|
||||
|
||||
/// Clear discover caches (call when provider filter changes)
|
||||
/// This ensures discover rows are refetched with the new provider filter
|
||||
void clearDiscoverCaches() {
|
||||
_cachedDiscoverArtists = null;
|
||||
_discoverArtistsLastFetched = null;
|
||||
_cachedDiscoverAlbums = null;
|
||||
_discoverAlbumsLastFetched = null;
|
||||
_cachedRecentAlbums = null;
|
||||
_recentAlbumsLastFetched = null;
|
||||
_searchCache.clear();
|
||||
_searchCacheTime.clear();
|
||||
_logger.log('🗑️ Discover caches cleared for provider filter change');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DETAIL SCREEN CACHING
|
||||
// ============================================================================
|
||||
@@ -126,15 +248,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;
|
||||
@@ -152,15 +282,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;
|
||||
@@ -178,9 +316,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
|
||||
// ============================================================================
|
||||
@@ -202,9 +348,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
|
||||
// ============================================================================
|
||||
@@ -245,9 +399,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);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -265,6 +430,7 @@ class CacheService {
|
||||
_searchCache.clear();
|
||||
_searchCacheTime.clear();
|
||||
_playerTrackCache.clear();
|
||||
_playerTrackCacheTime.clear();
|
||||
_logger.log('🗑️ All detail caches cleared');
|
||||
}
|
||||
|
||||
@@ -276,4 +442,108 @@ class CacheService {
|
||||
_cachedSelectedPlayer = null;
|
||||
_playersLastFetched = null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATABASE PERSISTENCE FOR HOME ROWS
|
||||
// ============================================================================
|
||||
|
||||
/// Load home row data from database for instant display on startup
|
||||
Future<void> loadHomeRowsFromDatabase() async {
|
||||
if (_homeRowsLoaded) return;
|
||||
_homeRowsLoaded = true;
|
||||
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
|
||||
// Load recent albums
|
||||
final recentData = await DatabaseService.instance.getHomeRowCache('recent_albums');
|
||||
if (recentData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(recentData.itemsJson) as List)
|
||||
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedRecentAlbums = items;
|
||||
_recentAlbumsLastFetched = recentData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} recent albums from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse recent albums: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load discover artists
|
||||
final artistsData = await DatabaseService.instance.getHomeRowCache('discover_artists');
|
||||
if (artistsData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(artistsData.itemsJson) as List)
|
||||
.map((json) => Artist.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedDiscoverArtists = items;
|
||||
_discoverArtistsLastFetched = artistsData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} discover artists from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse discover artists: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Load discover albums
|
||||
final albumsData = await DatabaseService.instance.getHomeRowCache('discover_albums');
|
||||
if (albumsData != null) {
|
||||
try {
|
||||
final items = (jsonDecode(albumsData.itemsJson) as List)
|
||||
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
_cachedDiscoverAlbums = items;
|
||||
_discoverAlbumsLastFetched = albumsData.lastUpdated;
|
||||
_logger.log('📦 Loaded ${items.length} discover albums from database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to parse discover albums: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Error loading home rows from database: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist home row data to database (fire-and-forget)
|
||||
void _persistHomeRowToDatabase(String rowType, List<Map<String, dynamic>> items) {
|
||||
() async {
|
||||
try {
|
||||
if (!DatabaseService.instance.isInitialized) return;
|
||||
await DatabaseService.instance.saveHomeRowCache(rowType, jsonEncode(items));
|
||||
_logger.log('💾 Persisted $rowType to database');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to persist $rowType: $e');
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,15 +107,18 @@ class DatabaseService {
|
||||
// ============================================
|
||||
|
||||
/// Cache an item with JSON serialization
|
||||
/// [sourceProvider] - optional provider instance ID that provided this item (for client-side filtering)
|
||||
Future<void> cacheItem<T>({
|
||||
required String itemType,
|
||||
required String itemId,
|
||||
required Map<String, dynamic> data,
|
||||
String? sourceProvider,
|
||||
}) {
|
||||
return db.cacheItem(
|
||||
itemType: itemType,
|
||||
itemId: itemId,
|
||||
data: jsonEncode(data),
|
||||
sourceProvider: sourceProvider,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,6 +135,29 @@ class DatabaseService {
|
||||
}).where((item) => item.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Get cached items with source provider info for client-side filtering
|
||||
/// Returns list of (data, sourceProviders) tuples
|
||||
Future<List<(Map<String, dynamic>, List<String>)>> getCachedItemsWithProviders(String itemType) async {
|
||||
final items = await db.getCachedItems(itemType);
|
||||
return items.map((item) {
|
||||
try {
|
||||
final data = jsonDecode(item.data) as Map<String, dynamic>;
|
||||
List<String> providers = [];
|
||||
try {
|
||||
if (item.sourceProviders.isNotEmpty && item.sourceProviders != '[]') {
|
||||
providers = List<String>.from(
|
||||
jsonDecode(item.sourceProviders) as List,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
return (data, providers);
|
||||
} catch (e) {
|
||||
_logger.log('Error decoding cached item: $e');
|
||||
return (<String, dynamic>{}, <String>[]);
|
||||
}
|
||||
}).where((item) => item.$1.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Get a single cached item
|
||||
Future<Map<String, dynamic>?> getCachedItem(String itemType, String itemId) async {
|
||||
final item = await db.getCachedItem(itemType, itemId);
|
||||
@@ -157,6 +183,23 @@ 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]);
|
||||
}
|
||||
|
||||
/// Batch cache multiple items efficiently
|
||||
/// Uses drift's batch API for much better performance on large datasets.
|
||||
/// Items are automatically chunked into batches of 500.
|
||||
Future<void> batchCacheItems(List<BatchCacheItem> items) {
|
||||
return db.batchCacheItems(items);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Player Cache Convenience Methods
|
||||
// ============================================
|
||||
@@ -222,4 +265,83 @@ class DatabaseService {
|
||||
|
||||
/// Clear queue for a player
|
||||
Future<void> clearCachedQueue(String playerId) => db.clearCachedQueue(playerId);
|
||||
|
||||
// ============================================
|
||||
// Home Row Cache Convenience Methods
|
||||
// ============================================
|
||||
|
||||
/// Save home row data (recent albums, discover artists, etc.)
|
||||
Future<void> saveHomeRowCache(String rowType, String itemsJson) {
|
||||
return db.saveHomeRowCache(rowType, itemsJson);
|
||||
}
|
||||
|
||||
/// Get cached home row data
|
||||
Future<HomeRowCacheData?> getHomeRowCache(String rowType) {
|
||||
return db.getHomeRowCache(rowType);
|
||||
}
|
||||
|
||||
/// Clear all home row cache
|
||||
Future<void> clearHomeRowCache() => db.clearHomeRowCache();
|
||||
|
||||
// ============================================
|
||||
// Search History Convenience Methods
|
||||
// ============================================
|
||||
|
||||
/// Save a search query to history
|
||||
Future<void> saveSearchQuery(String query) => db.saveSearchQuery(query);
|
||||
|
||||
/// Get recent search queries (up to 10)
|
||||
Future<List<String>> getRecentSearches() => db.getRecentSearches();
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,99 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
/// Service to generate unique per-installation player IDs
|
||||
/// Simplified to match KMP client pattern: single storage key, lazy generation
|
||||
/// This prevents the critical bug where multiple devices of the same model
|
||||
/// would share the same player ID and trigger playback on all devices
|
||||
/// Service to generate unique per-device player IDs
|
||||
/// Uses stable device identifiers (ANDROID_ID) combined with MA username
|
||||
/// to create persistent player IDs that survive storage wipes and support
|
||||
/// multiple devices per account
|
||||
class DeviceIdService {
|
||||
static const String _keyLocalPlayerId = 'local_player_id';
|
||||
static final _logger = DebugLogger();
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Get or generate a unique player ID for this installation
|
||||
/// ID is generated once and persisted across app restarts
|
||||
/// Format: ensemble_<uuid>
|
||||
///
|
||||
/// This matches the KMP client pattern: generate on first access, store once
|
||||
static Future<String> getOrCreateDevicePlayerId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// Cache stable device ID to avoid repeated platform calls
|
||||
static String? _cachedStableDeviceId;
|
||||
|
||||
// Single source of truth - no complex migration logic
|
||||
final existingId = prefs.getString(_keyLocalPlayerId);
|
||||
if (existingId != null && existingId.startsWith('ensemble_')) {
|
||||
_logger.log('Using existing player ID: $existingId');
|
||||
return existingId;
|
||||
/// Get stable device identifier that persists across storage wipes
|
||||
/// - Android: Uses ANDROID_ID (persists until factory reset)
|
||||
/// - iOS: Uses identifierForVendor (persists until app reinstall)
|
||||
static Future<String> _getStableDeviceId() async {
|
||||
if (_cachedStableDeviceId != null) {
|
||||
return _cachedStableDeviceId!;
|
||||
}
|
||||
|
||||
// Generate new ID on first access
|
||||
final playerId = 'ensemble_${_uuid.v4()}';
|
||||
await prefs.setString(_keyLocalPlayerId, playerId);
|
||||
_logger.log('Generated new player ID: $playerId');
|
||||
return playerId;
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final android = await deviceInfo.androidInfo;
|
||||
// ANDROID_ID is unique per device+user combo, persists across storage wipes
|
||||
// Only resets on factory reset
|
||||
_cachedStableDeviceId = android.id;
|
||||
} else if (Platform.isIOS) {
|
||||
final ios = await deviceInfo.iosInfo;
|
||||
// identifierForVendor persists across app updates but resets on reinstall
|
||||
_cachedStableDeviceId = ios.identifierForVendor ?? 'unknown_ios';
|
||||
} else {
|
||||
// Fallback for other platforms (web, desktop)
|
||||
_cachedStableDeviceId = 'unknown_platform';
|
||||
}
|
||||
|
||||
return _cachedStableDeviceId!;
|
||||
}
|
||||
|
||||
/// Detect if device is a tablet based on screen size
|
||||
/// Uses the standard 600dp threshold (shortest side >= 600 = tablet)
|
||||
static bool get isTablet {
|
||||
try {
|
||||
final view = ui.PlatformDispatcher.instance.views.first;
|
||||
final size = view.physicalSize / view.devicePixelRatio;
|
||||
return size.shortestSide >= 600;
|
||||
} catch (e) {
|
||||
_logger.log('Failed to detect device type: $e');
|
||||
return false; // Default to phone if detection fails
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or generate a unique player ID for this device
|
||||
/// Format: ensemble_{username}_{deviceId} or ensemble_{deviceId}
|
||||
///
|
||||
/// Key behaviors:
|
||||
/// - Uses stable device ID that survives storage wipes
|
||||
/// - Includes MA username to support multiple devices per account
|
||||
/// - Regenerates if username changes (user logged in with different account)
|
||||
static Future<String> getOrCreateDevicePlayerId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stableDeviceId = await _getStableDeviceId();
|
||||
final maUsername = await SettingsService.getUsername();
|
||||
|
||||
// Generate expected ID based on current username and device
|
||||
// Use shorter device ID suffix (first 8 chars) to keep ID reasonable length
|
||||
final deviceIdSuffix = stableDeviceId.length > 8
|
||||
? stableDeviceId.substring(0, 8)
|
||||
: stableDeviceId;
|
||||
|
||||
final expectedId = maUsername != null && maUsername.isNotEmpty
|
||||
? 'ensemble_${maUsername}_$deviceIdSuffix'
|
||||
: 'ensemble_$deviceIdSuffix';
|
||||
|
||||
// Check if we already have the correct ID
|
||||
final existingId = prefs.getString(_keyLocalPlayerId);
|
||||
if (existingId == expectedId) {
|
||||
_logger.log('Using existing player ID: $expectedId');
|
||||
return expectedId;
|
||||
}
|
||||
|
||||
// ID needs to be updated (first login, username changed, or migration)
|
||||
if (existingId != null) {
|
||||
_logger.log('Updating player ID from $existingId to $expectedId');
|
||||
} else {
|
||||
_logger.log('Generated new player ID: $expectedId');
|
||||
}
|
||||
|
||||
await prefs.setString(_keyLocalPlayerId, expectedId);
|
||||
return expectedId;
|
||||
}
|
||||
|
||||
/// Adopt an existing player ID (used when claiming a ghost player)
|
||||
@@ -45,4 +108,12 @@ class DeviceIdService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyLocalPlayerId) == null;
|
||||
}
|
||||
|
||||
/// Force regeneration of player ID on next call
|
||||
/// Useful after login/logout to ensure ID matches current user
|
||||
static Future<void> invalidateCachedId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keyLocalPlayerId);
|
||||
_logger.log('Invalidated cached player ID');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ enum ErrorType {
|
||||
network,
|
||||
playback,
|
||||
library,
|
||||
remote, // Remote connection (WebRTC) errors
|
||||
dataChannel, // WebRTC data channel errors
|
||||
signaling, // WebRTC signaling server errors
|
||||
unknown,
|
||||
}
|
||||
|
||||
@@ -107,6 +110,41 @@ class ErrorHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// WebRTC/Remote connection errors
|
||||
if (errorStr.contains('webrtc') ||
|
||||
errorStr.contains('remote peer') ||
|
||||
errorStr.contains('remote server disconnected')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.remote,
|
||||
userMessage: 'Remote connection lost. Attempting to reconnect...',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Data channel errors
|
||||
if (errorStr.contains('data channel') ||
|
||||
errorStr.contains('datachannel')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.dataChannel,
|
||||
userMessage: 'Remote data connection failed. Please try reconnecting.',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Signaling errors
|
||||
if (errorStr.contains('signaling') ||
|
||||
errorStr.contains('ice') ||
|
||||
errorStr.contains('peer connection failed')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.signaling,
|
||||
userMessage: 'Failed to establish remote connection. Please try again.',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown errors
|
||||
return ErrorInfo(
|
||||
type: ErrorType.unknown,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,19 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import '../utils/lru_cache.dart';
|
||||
|
||||
class MetadataService {
|
||||
static final _logger = DebugLogger();
|
||||
// Cache to avoid repeated API calls for the same artist/album
|
||||
static final Map<String, String> _cache = {};
|
||||
|
||||
// Cache for artist images
|
||||
static final Map<String, String?> _artistImageCache = {};
|
||||
// LRU caches to avoid repeated API calls and prevent unbounded memory growth
|
||||
// Using NullableLruCache for caches that store null values (failed lookups)
|
||||
|
||||
/// Cache for artist/album descriptions (100 entries)
|
||||
static final LruCache<String, String> _descriptionCache = LruCache(maxSize: 100);
|
||||
|
||||
/// Cache for artist images - includes null entries for failed lookups (200 entries)
|
||||
static final NullableLruCache<String, String> _artistImageCache = NullableLruCache(maxSize: 200);
|
||||
|
||||
/// Fetches artist biography/description with fallback chain:
|
||||
/// 1. Music Assistant metadata (passed in)
|
||||
@@ -34,8 +39,8 @@ class MetadataService {
|
||||
|
||||
// Check cache
|
||||
final cacheKey = 'artist:$artistName';
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
return _cache[cacheKey];
|
||||
if (_descriptionCache.containsKey(cacheKey)) {
|
||||
return _descriptionCache[cacheKey];
|
||||
}
|
||||
|
||||
// Try Last.fm API
|
||||
@@ -43,7 +48,7 @@ class MetadataService {
|
||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
|
||||
if (lastFmDesc != null) {
|
||||
_cache[cacheKey] = lastFmDesc;
|
||||
_descriptionCache[cacheKey] = lastFmDesc;
|
||||
return lastFmDesc;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +58,7 @@ class MetadataService {
|
||||
if (audioDbKey != null && audioDbKey.isNotEmpty) {
|
||||
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
|
||||
if (audioDbDesc != null) {
|
||||
_cache[cacheKey] = audioDbDesc;
|
||||
_descriptionCache[cacheKey] = audioDbDesc;
|
||||
return audioDbDesc;
|
||||
}
|
||||
}
|
||||
@@ -83,8 +88,8 @@ class MetadataService {
|
||||
|
||||
// Check cache
|
||||
final cacheKey = 'album:$artistName:$albumName';
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
return _cache[cacheKey];
|
||||
if (_descriptionCache.containsKey(cacheKey)) {
|
||||
return _descriptionCache[cacheKey];
|
||||
}
|
||||
|
||||
// Try Last.fm API (TheAudioDB doesn't have good album info)
|
||||
@@ -92,7 +97,7 @@ class MetadataService {
|
||||
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
|
||||
if (lastFmDesc != null) {
|
||||
_cache[cacheKey] = lastFmDesc;
|
||||
_descriptionCache[cacheKey] = lastFmDesc;
|
||||
return lastFmDesc;
|
||||
}
|
||||
}
|
||||
@@ -251,11 +256,11 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache for album images
|
||||
static final Map<String, String?> _albumImageCache = {};
|
||||
/// Cache for album images - includes null entries for failed lookups (200 entries)
|
||||
static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
|
||||
|
||||
// Cache for author images (audiobooks)
|
||||
static final Map<String, String?> _authorImageCache = {};
|
||||
/// Cache for author images (audiobooks) - includes null entries for failed lookups (100 entries)
|
||||
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
|
||||
|
||||
/// Fetches album cover URL from Deezer (free, no API key required)
|
||||
/// Returns the image URL if found, null otherwise
|
||||
@@ -326,120 +331,301 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Fetches author image URL from multiple sources
|
||||
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
|
||||
/// Returns the image URL if found, null otherwise
|
||||
static Future<String?> getAuthorImageUrl(String authorName) async {
|
||||
// Check cache first
|
||||
final cacheKey = 'authorImage:$authorName';
|
||||
if (_authorImageCache.containsKey(cacheKey)) {
|
||||
return _authorImageCache[cacheKey];
|
||||
/// Generate name variations for fuzzy matching
|
||||
/// Returns a list of name variations to try, in priority order
|
||||
static List<String> _generateNameVariations(String authorName) {
|
||||
final variations = <String>[];
|
||||
final original = authorName.trim();
|
||||
|
||||
// Always try original first
|
||||
variations.add(original);
|
||||
|
||||
// Handle "Lastname, Firstname" format
|
||||
if (original.contains(',')) {
|
||||
final parts = original.split(',').map((p) => p.trim()).toList();
|
||||
if (parts.length == 2) {
|
||||
variations.add('${parts[1]} ${parts[0]}'); // "Firstname Lastname"
|
||||
}
|
||||
}
|
||||
|
||||
// Try Audnexus (specifically for audiobook authors, uses Audible data)
|
||||
// Normalize and expand initials for better matching
|
||||
// Handles messy formats like "J. R.R Tolkien", "J.R.R. Tolkien", etc.
|
||||
String normalized = original;
|
||||
|
||||
// First, normalize spacing around periods in initials: "R.R" -> "R. R", "J. R" -> "J. R"
|
||||
// Match single letter followed by period and optional space, then another letter
|
||||
normalized = normalized.replaceAllMapped(
|
||||
RegExp(r'([A-Z])\.(\s?)([A-Z])'),
|
||||
(m) => '${m.group(1)}. ${m.group(3)}',
|
||||
);
|
||||
|
||||
// Also handle single letters without periods followed by period-letter: "R.R" in "J. R.R"
|
||||
// This catches the case where we have "R.R" as a unit
|
||||
normalized = normalized.replaceAllMapped(
|
||||
RegExp(r'(\s)([A-Z])\.([A-Z])'),
|
||||
(m) => '${m.group(1)}${m.group(2)}. ${m.group(3)}.',
|
||||
);
|
||||
|
||||
// Add periods to standalone single uppercase letters (initials without periods)
|
||||
// "J. R. R Tolkien" -> "J. R. R. Tolkien"
|
||||
normalized = normalized.replaceAllMapped(
|
||||
RegExp(r'(\s)([A-Z])(\s)'),
|
||||
(m) => '${m.group(1)}${m.group(2)}.${m.group(3)}',
|
||||
);
|
||||
|
||||
if (normalized != original && !variations.contains(normalized)) {
|
||||
variations.add(normalized);
|
||||
}
|
||||
|
||||
// Remove common titles
|
||||
final titles = ['Dr.', 'Dr', 'Mr.', 'Mr', 'Mrs.', 'Mrs', 'Ms.', 'Ms',
|
||||
'Prof.', 'Prof', 'Professor', 'Sir', 'Dame', 'Lord', 'Lady'];
|
||||
String withoutTitle = original;
|
||||
for (final title in titles) {
|
||||
if (withoutTitle.toLowerCase().startsWith(title.toLowerCase() + ' ')) {
|
||||
withoutTitle = withoutTitle.substring(title.length).trim();
|
||||
if (withoutTitle != original) {
|
||||
variations.add(withoutTitle);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove suffixes
|
||||
final suffixes = [', Jr.', ', Jr', ' Jr.', ' Jr', ', Sr.', ', Sr', ' Sr.', ' Sr',
|
||||
', III', ' III', ', II', ' II', ', IV', ' IV', ', PhD', ', Ph.D.'];
|
||||
String withoutSuffix = withoutTitle;
|
||||
for (final suffix in suffixes) {
|
||||
if (withoutSuffix.toLowerCase().endsWith(suffix.toLowerCase())) {
|
||||
withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length - suffix.length).trim();
|
||||
if (withoutSuffix != withoutTitle && !variations.contains(withoutSuffix)) {
|
||||
variations.add(withoutSuffix);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Split into parts for further variations (use normalized name for better splitting)
|
||||
final nameToSplit = normalized != original ? normalized : withoutSuffix;
|
||||
final parts = nameToSplit.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// Try first + last only (skip middle names/initials)
|
||||
final firstLast = '${parts.first} ${parts.last}';
|
||||
if (!variations.contains(firstLast)) {
|
||||
variations.add(firstLast);
|
||||
}
|
||||
|
||||
// Check if name starts with initials (single letter or letter with period)
|
||||
bool isInitial(String s) => s.length == 1 || (s.length == 2 && s.endsWith('.'));
|
||||
|
||||
// If first part looks like an initial, try just the last name
|
||||
if (isInitial(parts.first)) {
|
||||
if (!variations.contains(parts.last)) {
|
||||
variations.add(parts.last);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multiple initials like "J. K. Rowling" or "J. R. R. Tolkien"
|
||||
// Find where initials end and actual name begins
|
||||
int initialsEnd = 0;
|
||||
for (int i = 0; i < parts.length - 1; i++) {
|
||||
if (isInitial(parts[i])) {
|
||||
initialsEnd = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (initialsEnd > 0 && initialsEnd < parts.length) {
|
||||
// Try the non-initial part of the name
|
||||
final restOfName = parts.sublist(initialsEnd).join(' ');
|
||||
if (restOfName.isNotEmpty && !variations.contains(restOfName)) {
|
||||
variations.add(restOfName);
|
||||
}
|
||||
|
||||
// Also try initials without periods + last name (e.g., "JRR Tolkien", "JK Rowling")
|
||||
final initialsNoPeriods = parts.sublist(0, initialsEnd)
|
||||
.map((s) => s.replaceAll('.', ''))
|
||||
.join('');
|
||||
final compactInitials = '$initialsNoPeriods ${parts.last}';
|
||||
if (!variations.contains(compactInitials)) {
|
||||
variations.add(compactInitials);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates while preserving order
|
||||
return variations.toSet().toList();
|
||||
}
|
||||
|
||||
/// Try to fetch author image from Audnexus
|
||||
/// Audnexus requires a two-step process:
|
||||
/// 1. Search by name to get the ASIN
|
||||
/// 2. Fetch full author details by ASIN to get the image
|
||||
static Future<String?> _tryAudnexus(String name) async {
|
||||
try {
|
||||
final audnexusUri = Uri.https(
|
||||
'api.audnex.us',
|
||||
'/authors',
|
||||
{'name': authorName},
|
||||
);
|
||||
// Step 1: Search for author to get ASIN
|
||||
final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
|
||||
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
final audnexusResponse = await http.get(audnexusUri).timeout(const Duration(seconds: 5));
|
||||
if (searchResponse.statusCode == 200) {
|
||||
final searchData = json.decode(searchResponse.body);
|
||||
if (searchData is List && searchData.isNotEmpty) {
|
||||
// Find the best match - prefer exact name matches
|
||||
String? bestAsin;
|
||||
final nameLower = name.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
|
||||
|
||||
if (audnexusResponse.statusCode == 200) {
|
||||
final data = json.decode(audnexusResponse.body);
|
||||
// Audnexus returns an array of authors
|
||||
if (data is List && data.isNotEmpty) {
|
||||
final author = data[0];
|
||||
final imageUrl = author['image'] as String?;
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
_authorImageCache[cacheKey] = imageUrl;
|
||||
return imageUrl;
|
||||
for (final result in searchData) {
|
||||
final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
|
||||
final asin = result['asin'] as String?;
|
||||
if (asin != null) {
|
||||
if (resultName == nameLower) {
|
||||
bestAsin = asin;
|
||||
break; // Exact match found
|
||||
}
|
||||
bestAsin ??= asin; // Keep first result as fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (bestAsin != null) {
|
||||
// Step 2: Fetch full author details by ASIN
|
||||
final detailsUri = Uri.https('api.audnex.us', '/authors/$bestAsin');
|
||||
final detailsResponse = await http.get(detailsUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (detailsResponse.statusCode == 200) {
|
||||
final detailsData = json.decode(detailsResponse.body);
|
||||
final imageUrl = detailsData['image'] as String?;
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Audnexus author image error: $e', context: 'Metadata');
|
||||
_logger.warning('Audnexus author image error for "$name": $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fall back to Open Library API
|
||||
/// Try to fetch author image from Open Library
|
||||
static Future<String?> _tryOpenLibrary(String name) async {
|
||||
try {
|
||||
final uri = Uri.https(
|
||||
'openlibrary.org',
|
||||
'/search/authors.json',
|
||||
{
|
||||
'q': authorName,
|
||||
'limit': '1',
|
||||
},
|
||||
);
|
||||
|
||||
final uri = Uri.https('openlibrary.org', '/search/authors.json', {
|
||||
'q': name,
|
||||
'limit': '1',
|
||||
});
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final authors = data['docs'] as List?;
|
||||
if (authors != null && authors.isNotEmpty) {
|
||||
final author = authors[0];
|
||||
final authorKey = author['key'] as String?;
|
||||
final authorKey = authors[0]['key'] as String?;
|
||||
if (authorKey != null) {
|
||||
// Open Library author images are available at:
|
||||
// https://covers.openlibrary.org/a/olid/{OLID}-L.jpg
|
||||
final olid = authorKey.replaceAll('/authors/', '');
|
||||
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
|
||||
|
||||
// Verify the image actually exists (Open Library returns 404 for missing images)
|
||||
// Verify image exists - Open Library returns 302 redirects for valid images
|
||||
try {
|
||||
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
|
||||
if (imageCheck.statusCode == 200) {
|
||||
_authorImageCache[cacheKey] = imageUrl;
|
||||
final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
|
||||
// Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
|
||||
if (check.statusCode == 200 || check.statusCode == 301 ||
|
||||
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
|
||||
return imageUrl;
|
||||
}
|
||||
} catch (_) {
|
||||
// Image doesn't exist, continue to cache null
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Open Library author image error for "$name": $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Try to fetch author image from Wikidata (structured Wikipedia data)
|
||||
static Future<String?> _tryWikidata(String name) async {
|
||||
try {
|
||||
// Search for entity by name
|
||||
final searchUri = Uri.https('www.wikidata.org', '/w/api.php', {
|
||||
'action': 'wbsearchentities',
|
||||
'search': name,
|
||||
'language': 'en',
|
||||
'type': 'item',
|
||||
'limit': '3',
|
||||
'format': 'json',
|
||||
});
|
||||
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (searchResponse.statusCode == 200) {
|
||||
final searchData = json.decode(searchResponse.body);
|
||||
final results = searchData['search'] as List?;
|
||||
|
||||
// Try each result to find one with an image
|
||||
for (final result in results ?? []) {
|
||||
final entityId = result['id'] as String?;
|
||||
if (entityId == null) continue;
|
||||
|
||||
// Get the image property (P18)
|
||||
final claimsUri = Uri.https('www.wikidata.org', '/w/api.php', {
|
||||
'action': 'wbgetclaims',
|
||||
'entity': entityId,
|
||||
'property': 'P18', // P18 is the "image" property
|
||||
'format': 'json',
|
||||
});
|
||||
final claimsResponse = await http.get(claimsUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (claimsResponse.statusCode == 200) {
|
||||
final claimsData = json.decode(claimsResponse.body);
|
||||
final claims = claimsData['claims'] as Map<String, dynamic>?;
|
||||
final imageProperty = claims?['P18'] as List?;
|
||||
|
||||
if (imageProperty != null && imageProperty.isNotEmpty) {
|
||||
final imageName = imageProperty[0]['mainsnak']?['datavalue']?['value'] as String?;
|
||||
if (imageName != null && imageName.isNotEmpty) {
|
||||
// Convert filename to Wikimedia Commons URL
|
||||
final encodedName = Uri.encodeComponent(imageName.replaceAll(' ', '_'));
|
||||
return 'https://commons.wikimedia.org/wiki/Special:FilePath/$encodedName?width=500';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Open Library author image error: $e', context: 'Metadata');
|
||||
_logger.warning('Wikidata author image error for "$name": $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fall back to Wikipedia API
|
||||
/// Try to fetch author image from Wikipedia
|
||||
static Future<String?> _tryWikipedia(String name) async {
|
||||
try {
|
||||
// Search for author on Wikipedia
|
||||
final searchUri = Uri.https(
|
||||
'en.wikipedia.org',
|
||||
'/w/api.php',
|
||||
{
|
||||
'action': 'query',
|
||||
'list': 'search',
|
||||
'srsearch': '$authorName writer',
|
||||
'srlimit': '1',
|
||||
'format': 'json',
|
||||
},
|
||||
);
|
||||
|
||||
// Search for author - "writer author" suffix helps find the right page
|
||||
final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
|
||||
'action': 'query',
|
||||
'list': 'search',
|
||||
'srsearch': '$name writer author',
|
||||
'srlimit': '1',
|
||||
'format': 'json',
|
||||
});
|
||||
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (searchResponse.statusCode == 200) {
|
||||
final searchData = json.decode(searchResponse.body);
|
||||
final searchResults = searchData['query']?['search'] as List?;
|
||||
if (searchResults != null && searchResults.isNotEmpty) {
|
||||
final pageTitle = searchResults[0]['title'] as String?;
|
||||
final results = searchData['query']?['search'] as List?;
|
||||
if (results != null && results.isNotEmpty) {
|
||||
final pageTitle = results[0]['title'] as String?;
|
||||
if (pageTitle != null) {
|
||||
// Get page image
|
||||
final imageUri = Uri.https(
|
||||
'en.wikipedia.org',
|
||||
'/w/api.php',
|
||||
{
|
||||
'action': 'query',
|
||||
'titles': pageTitle,
|
||||
'prop': 'pageimages',
|
||||
'pithumbsize': '500',
|
||||
'format': 'json',
|
||||
},
|
||||
);
|
||||
|
||||
final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
|
||||
'action': 'query',
|
||||
'titles': pageTitle,
|
||||
'prop': 'pageimages',
|
||||
'pithumbsize': '500',
|
||||
'format': 'json',
|
||||
});
|
||||
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (imageResponse.statusCode == 200) {
|
||||
@@ -450,7 +636,6 @@ class MetadataService {
|
||||
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
|
||||
final imageUrl = thumbnail?['source'] as String?;
|
||||
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||
_authorImageCache[cacheKey] = imageUrl;
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
@@ -459,7 +644,100 @@ class MetadataService {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Wikipedia author image error: $e', context: 'Metadata');
|
||||
_logger.warning('Wikipedia author image error for "$name": $e', context: 'Metadata');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Fetches author image URL from multiple sources with fuzzy name matching
|
||||
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
|
||||
/// Tries name variations if exact match fails
|
||||
static Future<String?> getAuthorImageUrl(String authorName) async {
|
||||
// Check cache first
|
||||
final cacheKey = 'authorImage:$authorName';
|
||||
if (_authorImageCache.containsKey(cacheKey)) {
|
||||
return _authorImageCache[cacheKey];
|
||||
}
|
||||
|
||||
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
|
||||
// Try to get image for the first/primary author
|
||||
if (authorName.contains(',')) {
|
||||
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
|
||||
// Skip if this looks like "Lastname, Firstname" format or has suffixes
|
||||
// - Second part is initials (J.R.R., J.K., etc.)
|
||||
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
|
||||
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
|
||||
if (authors.length == 2) {
|
||||
final secondPart = authors[1].toLowerCase();
|
||||
// Check if second part is a suffix or looks like initials
|
||||
if (suffixes.contains(secondPart) ||
|
||||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
|
||||
// This is "Lastname, First" or "Name, Jr." - don't split
|
||||
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
|
||||
// Both parts have multiple words - likely "Author1, Author2"
|
||||
for (final author in authors) {
|
||||
final result = await getAuthorImageUrl(author);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// Don't cache null here - fall through to try the full name
|
||||
}
|
||||
} else if (authors.length > 2) {
|
||||
// Multiple commas = definitely multiple authors
|
||||
for (final author in authors) {
|
||||
final result = await getAuthorImageUrl(author);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// Don't cache null - fall through to try full name
|
||||
}
|
||||
}
|
||||
|
||||
// Generate name variations for fuzzy matching
|
||||
final variations = _generateNameVariations(authorName);
|
||||
|
||||
// Try each source with all variations before moving to next source
|
||||
// This prioritizes source quality over name variation
|
||||
|
||||
// 1. Try Audnexus (best for audiobook authors)
|
||||
for (final name in variations) {
|
||||
final result = await _tryAudnexus(name);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try Open Library
|
||||
for (final name in variations) {
|
||||
final result = await _tryOpenLibrary(name);
|
||||
if (result != null) {
|
||||
_authorImageCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try Wikidata (structured data, good for finding exact person)
|
||||
for (final name in variations.take(2)) {
|
||||
final wikidataResult = await _tryWikidata(name);
|
||||
if (wikidataResult != null) {
|
||||
_authorImageCache[cacheKey] = wikidataResult;
|
||||
return wikidataResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try Wikipedia (search-based, good fallback)
|
||||
for (final name in variations.take(3)) {
|
||||
final wikiResult = await _tryWikipedia(name);
|
||||
if (wikiResult != null) {
|
||||
_authorImageCache[cacheKey] = wikiResult;
|
||||
return wikiResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the null result to avoid repeated failed lookups
|
||||
@@ -467,11 +745,90 @@ class MetadataService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Clears the metadata cache
|
||||
/// Cache for lyrics - uses bounded Map with manual size limiting (500 entries max)
|
||||
/// Stores (plainLyrics, syncedLyrics) tuples or null for failed lookups
|
||||
static final Map<String, (String?, String?)?> _lyricsCache = {};
|
||||
static const int _lyricsCacheMaxSize = 500;
|
||||
|
||||
/// Fetches lyrics from LRCLIB (free, no API key required)
|
||||
/// Returns a tuple of (plainLyrics, syncedLyrics) or null if not found
|
||||
/// LRCLIB provides both synced (LRC format) and plain lyrics
|
||||
static Future<(String?, String?)?> getTrackLyrics({
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
String? albumName,
|
||||
int? durationSeconds,
|
||||
}) async {
|
||||
// Build cache key
|
||||
final cacheKey = 'lyrics:$artistName:$trackName';
|
||||
if (_lyricsCache.containsKey(cacheKey)) {
|
||||
return _lyricsCache[cacheKey];
|
||||
}
|
||||
|
||||
// Try LRCLIB API
|
||||
try {
|
||||
final params = <String, String>{
|
||||
'track_name': trackName,
|
||||
'artist_name': artistName,
|
||||
};
|
||||
if (albumName != null && albumName.isNotEmpty) {
|
||||
params['album_name'] = albumName;
|
||||
}
|
||||
if (durationSeconds != null && durationSeconds > 0) {
|
||||
params['duration'] = durationSeconds.toString();
|
||||
}
|
||||
|
||||
final uri = Uri.https('lrclib.net', '/api/get', params);
|
||||
_logger.log('🎤 Fetching lyrics from LRCLIB: $trackName by $artistName');
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {
|
||||
'User-Agent': 'Ensemble Music Player (https://github.com/ensemble-app)',
|
||||
},
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final plainLyrics = data['plainLyrics'] as String?;
|
||||
final syncedLyrics = data['syncedLyrics'] as String?;
|
||||
|
||||
if ((plainLyrics != null && plainLyrics.isNotEmpty) ||
|
||||
(syncedLyrics != null && syncedLyrics.isNotEmpty)) {
|
||||
_logger.log('🎤 LRCLIB found lyrics: plain=${plainLyrics?.length ?? 0} chars, synced=${syncedLyrics?.length ?? 0} chars');
|
||||
final result = (plainLyrics, syncedLyrics);
|
||||
_evictLyricsCacheIfNeeded();
|
||||
_lyricsCache[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
} else if (response.statusCode == 404) {
|
||||
_logger.log('🎤 LRCLIB: No lyrics found for $trackName');
|
||||
} else {
|
||||
_logger.log('🎤 LRCLIB error: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('LRCLIB API error: $e', context: 'Metadata');
|
||||
}
|
||||
|
||||
// Cache the null result to avoid repeated failed lookups
|
||||
_evictLyricsCacheIfNeeded();
|
||||
_lyricsCache[cacheKey] = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Evict oldest entries from lyrics cache if over max size
|
||||
static void _evictLyricsCacheIfNeeded() {
|
||||
while (_lyricsCache.length >= _lyricsCacheMaxSize) {
|
||||
_lyricsCache.remove(_lyricsCache.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all metadata caches
|
||||
static void clearCache() {
|
||||
_cache.clear();
|
||||
_descriptionCache.clear();
|
||||
_artistImageCache.clear();
|
||||
_albumImageCache.clear();
|
||||
_authorImageCache.clear();
|
||||
_lyricsCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||